Construye aplicaciones con Laravel Agents SDK AI

Video thumbnail

Índice de contenido

Laravel AI SDK. Esta herramienta no es para ayudarte a programar, sino para que tu aplicación sea inteligente.

Si quieres que tu usuario pueda:

  • Generar una imagen de una "dona espacial" desde un formulario.
  • Traducir un comentario automáticamente.
  • Interactuar con un chatbot que use modelos locales o externos.

Entonces usas el SDK. Es una librería que te permite lanzar prompts directamente desde tu código PHP. A diferencia de Boost, esta es una dependencia normal de la aplicación, ya que formará parte de las funcionalidades que tus usuarios finales van a disfrutar.

Anteriormente vimos como usar Laravel Boost para codificar.

Instalación y Configuración del SDK de Laravel AI

A diferencia de Laravel Boost, el SDK de Laravel AI es el que nos permite ensuciarnos las manos creando nuestros propios prompts y lógicas de inteligencia artificial dentro de la app.

Primeros pasos:

  1. Instalación: Ejecutamos el comando de instalación:
    1. $ composer require laravel/ai
    2. $ php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
    3. $ php artisan migrate
  2. Base de Datos: Verás que se crean migraciones para tablas de conversaciones y agentes. Es fundamental ejecutar php artisan migrate para que el SDK tenga dónde guardar el historial y la configuración.
  3. Clave de API: En tu archivo .env, debes configurar tu clave.
    1. ANTHROPIC_API_KEY=
      COHERE_API_KEY=
      ELEVENLABS_API_KEY=
      GEMINI_API_KEY=
      MISTRAL_API_KEY=
      OLLAMA_API_KEY=
      OPENAI_API_KEY=
      JINA_API_KEY=
      VOYAGEAI_API_KEY=
      XAI_API_KEY=

Si buscas una opción gratuita para empezar, Google AI Studio es de las mejores. Solo tienes que entrar, generar tu API Key de Gemini y pegarla en tu proyecto.

El Corazón del SDK: Los Agentes

La gran diferencia aquí es que todo funciona mediante Agentes.

  • Un Agente es como un "empleado virtual" al que le asignas una tarea específica (ej: "Eres un traductor", "Eres un experto en SEO", "Eres un generador de imágenes").
  • Una vez configurado el agente, tú interactúas con él mediante prompts.
use Laravel\Ai\Enums\Lab;
use function Laravel\Ai\agent;
Route::get('/laravel-ia-text', function () {
    $response = agent(
        instructions: 'Eres un asistente experto en Laravel.',)->prompt(
        'Genera una lista de 3 temas de Laravel 13 en formato JSON',
        provider: Lab::Gemini,
        model: <YOURIDLOCALMODAL>,);
    
    dd($response);
});

Esto que vimos, es un agente anónimo ya que, la estructura recomendada es mediante agentes que veremos más adelante.

Google Antigravity: Potencia sin Complicaciones

Ya superamos la curva de aprendizaje de Laravel Boost y OpenCode, así que Google Antigravity te va a parecer un paseo. Es más sencillo porque es menos abstracto: es un IDE que ya trae la IA "en el ADN".

La gran diferencia: Integración vs. Personalización

Aquí hay que ser claros: no hacen "match".

  • OpenCode (VS Code): Es un entorno abierto. Tú instalas los agentes, configuras los archivos Markdown y tienes un control quirúrgico.
  • Anti Gravity: Es un entorno cerrado. Es más limitado en cuanto a reglas externas (no puedes copiar y pegar directamente las guías de OpenCode aquí), pero a cambio te ofrece modelos de primer nivel (Gemini 3 Pro, Claude, Sonnet) de forma gratuita en su plan actual.

Configuración y Reglas (Custom Instructions)

Aunque es más cerrado, no significa que no puedas darle órdenes. En los tres puntos de la interfaz puedes personalizar las reglas, ya sea de forma global o para un espacio de trabajo específico.

  • Al guardar estas reglas, se genera un archivo gemini.md (muy parecido a lo que vimos antes).
  • Mi consejo: Yo no pierdo mucho tiempo configurando esto aquí. Uso Anti Gravity para desarrollos puntuales donde yo mismo le doy toda la información necesaria en el prompt.

Agentes: Planificación vs. Fast (Fase)

Al igual que en OpenCode, aquí tenemos dos modos de trabajo:

  1. Modo Planificación: Ideal para tareas complejas. La IA analiza todo el proyecto, propone un plan y, si le das el OK, ejecuta. Incluso puede levantar un navegador (browser) para verificar si lo que hizo funciona en tiempo real.
  2. Modo Fast (Fase): Es el que más uso. Es para cambios rápidos y directos. Le dices: "Modifica este archivo para incluir el plugin de CKEditor" y lo hace al instante.

Mi Flujo de Trabajo

Cuando trabajo con AntiGravity, sigo un ciclo muy marcado:

  1. Iteración: Pido el cambio y espero a que termine.
  2. Prueba: Si funciona, sincronizo con Git (git commit).
  3. Corrección: Si no funciona, le pido que corrija.
  4. Reversión: Si veo que la IA se está liando y no da pie con bola, tiro de un git checkout o git reset, limpio la mesa y vuelvo a empezar desde un punto seguro.

Ventajas visuales

Una cosa que me encanta de esta herramienta es la ayuda visual. Te marca en amarillo exactamente qué líneas va a cambiar antes de aplicarlas. Eso te da una seguridad que no siempre tienes en la terminal de VS Code.

¿Cuál elegir? OpenCode vs. Antigravity

Si estás en el plan gratuito, este es el dilema:

  • OpenCode: Tienes una personalización total, puedes usar todos los skills de Laravel Boost y tienes una flexibilidad enorme, pero requiere que conozcas bien la herramienta.
  • Antigravity: Tienes los mejores modelos del mercado (incluso Claude, GPT…) gratis y una interfaz mucho más amable para el desarrollo rápido, pero con menos capacidad de auditoría profunda.

En resumen: uso OpenCode para auditorías, revisiones de base de datos o arquitecturas complejas de Laravel, y Antigravity para crear funcionalidades nuevas de forma rápida y visual.

Práctica de Rediseño con Google Antigravity

Vamos a poner a prueba Google Anti Gravity con un ejercicio real: un rediseño sencillo de la aplicación Laravel que estamos construyendo. Aunque confío en la herramienta, mi primera recomendación es siempre la misma: sincroniza con Git antes de empezar. Así, si el resultado no te gusta, un git reset te devuelve a la vida en segundos.

1. Preparando el Escenario (Contexto)

Para que la IA no alucine, lo mejor es darle contexto. En este caso:

  • Copié la ruta relativa de la carpeta css (donde están los estilos del blog y del dashboard).
  • Busqué una imagen de inspiración en internet para dársela como referencia visual.

2. El Prompt: Claro y Directo

Mi objetivo no es cambiar la estructura HTML (para no complicar el curso), sino solo el estilo. Usé el modo Fast  y el modelo Gemini 3 Flash. El prompt fue algo así:

"Haz un sencillo rediseño de la app, sobreescribiendo en lo posible solamente el CSS que está en la ruta:

resources/css/blog.css

resources/css/dashboard.css

 Quiero que te inspires en la imagen adjunta."

Nota: Si tienes dudas sobre cómo abordar un cambio grande, usa el modo Planificación; pero para algo tan puntual como esto, el modo Fast es perfecto.

Ejecución y Resultados

A diferencia de OpenCode, aquí ves cómo la IA empieza a escanear el proyecto de forma visual. Te marca en amarillo exactamente qué líneas de CSS va a cambiar o añadir. Es una ayuda visual brutal.

Este es un ejemplo perfecto de cómo interactuar con la IA en Anti Gravity:

  1. Pides: "Rediseña esto".
  2. Esperas: Ves cómo aplica los cambios visualmente.
  3. Pruebas: Te registras, te autenticas y navegas por la app.
  4. Decides: ¿Te gusta el resultado? Si la respuesta es "está bastante bien", sincronizas. Si no, reviertes con Git.

En mi caso, aunque hubo pequeños fallos que corregiré "por detrás", el resultado me sirve.

Implementar dialogo de confirmación al eliminar

Para cerrar esta práctica con Google Antigravity, vamos a resolver un detalle pendiente: el diálogo de confirmación para eliminar registros. Actualmente, si presionas "Eliminar", el post se borra directamente sin preguntar, lo cual es un peligro.

El Prompt y el Contexto

Lo bueno de Antigravity es que no hace falta ser excesivamente específico si el código está bien estructurado. Simplemente le pasé el archivo index.blade.php de categorías y le pedí:

Implementa un diálogo de confirmación para el botón de eliminar:

resources/views/dashboard/post/index.blade.php

resources/views/dashboard/category/index.blade.php

No tuve que decirle cuál era el botón; la IA analizó el contexto, encontró la etiqueta que dice "Eliminar" y entendió perfectamente dónde debía intervenir.

La Solución: JavaScript Nativo y Efectivo

Fíjate en lo que hizo la IA (puedes verlo en los globos amarillos de cambios): implementó una solución con JavaScript directamente en el evento onsubmit del formulario.

resources/views/dashboard/post/index.blade.php

resources/views/dashboard/category/index.blade.php

<form action="{{ route('category.destroy', $category) }}" method="POST" onsubmit="return confirm('¿Estás seguro de que deseas eliminar esta categoría?')">

¿Cómo funciona esta lógica?

  • Utiliza la función nativa confirm(), que despliega una ventana emergente en el navegador.
  • Esta función retorna un true (si das a Aceptar) o un false (si das a Cancelar).
  • Al colocarlo dentro del onsubmit, si el usuario cancela, el formulario recibe un false y detiene el envío automáticamente. Es una forma limpia y directa de proteger los datos sin necesidad de instalar librerías pesadas.

Conclusión

Esta segunda práctica demuestra por qué uso tanto Antigravity para lo que llamo "desarrollo espiritual" (esos ajustes rápidos que te dan paz mental).

Si hubiéramos querido algo más complejo, como un módulo separado en dashboard.js, la IA se habría encargado de crear el archivo, exportar el módulo e importarlo correctamente. Lo que a nosotros nos tomaría varios minutos de "carpintería" de código, la IA lo resuelve en 3 o 4 minutos.

Laravel AI SDK

¿Cómo usar un LLM Local con Laravel AI SDK y Jan.ai o LM Studio?

En este apartado te mostraré cómo emplear un modelo de lenguaje (LLM) local utilizando Jan.ai o LM Studio en conjunto con el Laravel AI SDK. Esta es una solución ideal si te encuentras en regiones donde servicios como Gemini o ChatGPT están bloqueados, o si simplemente buscas privacidad y ahorro de costos.

¿Qué es un LLM local?

Un LLM (Large Language Model) es una inteligencia artificial diseñada para comprender y generar lenguaje humano. Al ejecutarlo de forma local, aprovechas la potencia de tu propia máquina sin depender de una conexión a internet o de planes de pago. Las ventajas son claras:

  • Privacidad: Tus datos nunca salen de tu equipo.
  • Sin costos: No hay límites de tokens ni suscripciones mensuales.
  • Sin bloqueos: No dependes de restricciones geográficas.

Herramientas y Requisitos

Para correr una IA local, necesitas un software que gestione el modelo y un equipo con cierta potencia. Las opciones más populares son Ollama, LM Studio y Jan.ai.

Consideraciones de Hardware (RAM vs. VRAM)

La potencia del modelo que elijas dependerá de tu memoria:

  • Windows/Linux: Se apoyan mucho en la VRAM de la tarjeta de video. Si el modelo supera tu capacidad (ej. tienes 12GB de VRAM e intentas correr un modelo pesado), el sistema usará la memoria RAM, y el rendimiento caerá drásticamente.
  • Mac (Chips M): Utilizan memoria unificada. Esto significa que la CPU y la GPU comparten la misma RAM (ej. 24GB), lo que permite ejecutar modelos más grandes (como los de 31B de parámetros) con mayor fluidez.

Configuración del Servidor Local

Tanto Jan.ai como LM Studio siguen el estándar de la API de OpenAI, lo que facilita enormemente la integración con Laravel.

  • Instalación: Descarga e instala Jan.ai desde su sitio oficial.
  • Activar el Servidor: Ve a Settings > Local API Server y actívalo.
  • Endpoint: Por defecto, el servidor correrá en http://localhost:1337/v1.
  • API Key: Puedes configurar una clave sencilla (ej. 12345) en los ajustes del servidor local.

LM Studio al igual que Jan.IA es otra excelente opción que tenemos a nuestra disposición para instalar IAs en local y también cuenta con un servidor local al igual que Jan.IA, por lo tanto, podemos utilizarlo también para nuestro proyecto en Flask SIN PRACTICAMENTE CAMBIOS, pero, a diferencia de Jan, LM Studio NO emplea un token BEARER de protección y por defecto su puerto es el 1234 (que puedes cambiar); en la siguiente tabla, te resumo lo comentado

CaracterísticaJan.iaLM Studio
URL por defectohttp://localhost:1337/v1http://localhost:1234/v1
API KeyCualquier texto (ej. "sk-local")No la requiere / Ignorada
CompatibilidadEstilo OpenAI (v1)100% OpenAI-compatible
HeadersContent-Type + AuthorizationContent-Type (Auth opcional)
Motor Principalnitro (especializado en GGUF)llama.cpp

Puedes descargar LM Studio desde:

https://lmstudio.ai/

Su funcionamiento es muy similar al de Jan IA, así que no te perderás, por lo demás, LM Studio al igual que Jan, también puedes conectarte exactamente igual, porque LM Studio expone una API 100% compatible con OpenAI en los cuales, tenemos algunos endpoints interesantes que podemos emplear:

  • v1/chat/completions
  • /v1/models

LM Studio no usa API KEY

Esto significa que debes quitar el header Authorization, porque si lo mandas, LM Studio lo ignora o te lanza 401 en algunos casos.

Integración con Laravel AI SDK

Para conectar Laravel con tu IA local, debemos realizar un pequeño "truco" en el archivo .env. Como el SDK de Laravel no tiene un driver nativo para Jan.ai, utilizaremos el driver de Deepseek o OpenAI para "puentear" (OpenAI Driver Emulation / Emulación del Driver de OpenAI) la conexión.

Configuración en el .env

# Usamos el driver de Deepseek para aprovechar su compatibilidad de endpoints
AI_DRIVER=deepseek
DEEPSEEK_API_KEY=tu_clave_local_12345
DEEPSEEK_BASE_URL=http://localhost:1337/v1

Publicas el archivo de configuración:

$ php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"

Conecta un LLM Local con Jan IA a una app en Python con Flask

En este apartado, vamos a conectarnos al LLM en Local.

Activar el servidor local

Antes de conectar, debemos activar el servidor Jan.IA:

Settings → Local App Server → Establecer tu clave → Start

En LM Studio: Ve a la pestaña de Local Server (el icono de doble flecha o de desarrollador).

Presiona el botón Start Server. (A diferencia de Jan.ai, aquí no necesitas configurar una API Key; el acceso es directo a través de la URL base que te proporciona el programa.)

Controlador base para conectarse al LLM

En cuanto a lo principal, el controlador que se va a conectar al LLM, tenemos:

  • URL y cabeceras

Definimos la URL del endpoint al que le haremos la petición:

http://localhost:port/v1/chat/completions

Y las cabeceras que Jam necesita:

headers = {
    "Content-Type": "application/json",
    "Authorization": "Bearer 12345"
}
  • Modelo → el que tengas seleccionado en Jam (debes escribirlo idéntico, ejemplo: yema-3-12b).
  • Mensaje → array JSON con el rol user y el contenido.

En este ejemplo: “cuéntame una broma”.

data = {
    "model": "gemma-3-12b-it-IQ4_XS",
    "messages": [
        #   {"role": "user", "content": request.form.get("prompt", "Tell me a joke.")}
        {"role": "user", "content": prompt}
    ]
}

Haciendo la petición

Como esto puede fallar, lo envolvemos en un try.

Realizamos un requests.post(...) enviando:

  • URL.
  • headers.
  • json=data (no body, porque es JSON explícitamente).

Luego validamos la respuesta: si response.status_code == 200, todo perfecto.

  • 404 sería ruta no encontrada.
  • 500 = algo explotó en el servidor o mandaste algo inválido.

Ejecución en el código

Para realizar una petición, puedes usar un agente o una llamada directa. Es vital que el nombre del modelo coincida exactamente con el ID que te proporciona Jan.ai (puedes consultarlo en la lista de modelos descargados o vía Postman).

use Illuminate\Support\Facades\AI;
$response = AI::chat('Genera una lista de 3 temas de Laravel 13 en formato JSON')
   ->withDriver('deepseek')
   ->withModel('gemma-2-9b-it') // Reemplaza por tu ID de modelo local
   ->send();
return $response;

Adicionalmente, usamos deepseek como "puente" para conectar Laravel con herramientas locales como Jan.ia o LM Studio:

1. El estándar de la industria (OpenAI API Format)

Casi todas las herramientas locales (Jan.ia, LM Studio, Ollama) no inventan su propia forma de recibir datos. En su lugar, imitan (clonan) la estructura de la API de OpenAI.

Prism-php y el SDK de Laravel AI suelen tener drivers específicos. Si el driver de openai está configurado para apuntar a la nube de OpenAI, a veces se recomienda usar el de deepseek porque:

  • DeepSeek usa exactamente el mismo formato de API que OpenAI.
  • Permite cambiar la base_url de forma sencilla hacia http://127.0.0.1:1337/v1 (tu Jan.ia).
  • Evita conflictos si ya estás usando el driver de openai oficial para otros servicios en la misma app.

Aunque el driver se llame deepseek, eso es solo el "cartero" que lleva el mensaje. El parámetro ->withModel('gemma-2-9b-it') es el que le dice a Jan.ia qué modelo debe usar realmente tu hardware.


¿Qué está pasando realmente?

Cuando ejecutas ese código:

  • Laravel le pasa el paquete al driver deepseek.
  • Prism-php lo envía al puerto 1337 de tu propia máquina.
  • Jan.ia recibe la petición, ve que pides gemma-2-9b-it y despierta a tu RTX 5070 para procesarlo.
  • Gemma genera el JSON y lo devuelve por el mismo camino.

Que modelo local seleccionar

Si no sabes qué modelo descargar, te recomiendo empezar por Gemma (de Google) o Llama. Si tienes dudas sobre cuál soporta tu equipo, puedes tomar una captura de tus especificaciones técnicas y preguntarle a la propia IA; ella te dirá si te conviene un modelo ligero de 7B parámetros o uno más robusto de 31B.

Prompt: Tengo una mac mini m4 pro, cual uso, dame una descripción para que usar cada uno y cual me recomienda

  1. Los "Pesados" (31B) - Para Razonamiento Complejo
    Modelos con 31 billones de parámetros. Son los más inteligentes de la lista.
    1. Gemma-4-31B-It-GGUF:
    2. Para qué: Es ideal para cuando necesitas que la IA entienda arquitectura de software compleja, resuelva bugs difíciles en Laravel 13 o redacte contenido educativo profundo para tu academia.
    3. Rendimiento en M4 Pro: Irá bien, pero consumirá mucha RAM (unos 16-18 GB solo el modelo). Si tienes 24GB o más de RAM, este es tu modelo principal.
  2. El "Equilibrio" (26B) - El todoterreno
    Modelos con 26 billones de parámetros.
    1. Gemma-4-26B-A4B-It-GGUF:
    2. Para qué: Es una arquitectura "Mixture of Experts" (MoE). Es más rápido que el de 31B pero mantiene casi la misma inteligencia. Perfecto para generación de código diaria y testing unitario.
    3. Rendimiento en M4 Pro: Volará. Es el punto dulce entre velocidad y "cerebro".
  3. Los "Ligeros" (E4B / E2B) - Para Automatización y Velocidad
    Modelos de 4 y 2 billones de parámetros.
    1. Gemma-4-E4B / E2B:
    2. Para qué: Son modelos "Edge", diseñados para ser ultra rápidos. Úsalos para tareas simples como autocompletado de código, formatear JSON o resumir textos cortos.
    3. Rendimiento en M4 Pro: Instantáneo. Casi no notarás que está procesando.

Análisis para la RTX 5070

A diferencia del procesador de Apple, la 5070 tiene núcleos Tensor diseñados específicamente para IA. Esto significa que los modelos que elijas responderán casi en tiempo real.

  • Gemma-4-26B-A4B-It-GGUF (Recomendado):
    • Por qué: Al ser una arquitectura MoE (Mixture of Experts) de 26B, solo activa una fracción de sus parámetros para cada palabra (en este caso, 4 billones activos).
    • En la 5070: Cabrá muy bien en la VRAM si usas una cuantización tipo Q4_K_M o Q5. Tendrás una velocidad de generación de texto superior a la de la Mac.
    • Gemma-4-31B-It-GGUF (El límite):
  • Por qué: Este modelo es denso (usa los 31B siempre).
    • En la 5070: Es probable que "desborde" la VRAM de la tarjeta. Cuando un modelo no cabe en la tarjeta de video, se pasa a la memoria RAM normal (el i7 14700K), y la velocidad cae drásticamente (de 50 tokens por segundo a 2 o 3).
    • Uso: Solo úsalo si necesitas la máxima precisión posible en un tutorial complejo y no te importa esperar unos segundos más.
    • Gemma-4-E4B-It-GGUF (Para desarrollo):
  • Por qué: Es pequeño (4B).
    • En la 5070: Es instantáneo. Puedes usarlo como un copiloto que te sugiera código en tiempo real mientras escribes tus libros o cursos de Laravel sin que el ventilador de la GPU se inmute.

¡Con esto ya tienes tu propio entorno de inteligencia artificial totalmente privado y funcional en Laravel!

Ejemplos Agentes Anónimos Prácticos: Chat, Código y Análisis de Sentimiento

Para que tengas más variedad, he preparado tres ejemplos adicionales. Aunque la base es similar a lo que hemos visto, estos casos te ayudarán a visualizar cómo estructurar un proyecto real y funcional en el futuro, más allá de una simple petición estática.

He creado un nuevo controlador con tres métodos específicos:

  • Chat General: Una conversación abierta.
  • Generación de Código: Creación de estructuras como migraciones de Laravel.
  • Análisis de Sentimiento: Determinar si un mensaje es positivo, negativo o neutro.

routes\web.php

// Chat básico
Route::get('/agents/chat', [AgentsTestController::class, 'chat']);
// Generación de código
Route::get('/agents/generar-codigo', [AgentsTestController::class, 'generarCodigo']);
// Análisis de sentimientos
Route::get('/agents/analizar', [AgentsTestController::class, 'analizar']);

app\Http\Controllers\AgentsTestController.php

<?php
namespace App\Http\Controllers;
use App\Ai\Agents\PokemonAgent;
use App\Ai\Agents\QuizAgent;
use App\Ai\Agents\QuizGenerator;
use App\Ai\Agents\SalesCoach;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Laravel\Ai\Enums\Lab;
use function Laravel\Ai\agent;
/**
 * Controlador para ejemplos de uso de Gemma 3 con IA local (Ollama).
 */
class AgentsTestController extends Controller
{
    /**
     * Chat básico
     */
    public function chat(Request $request): JsonResponse
    {
        $mensaje = $request->input('mensaje', 'Hola, dime qué sabes sobre Laravel');
        $response = agent(
            instructions: 'Eres un asistente útil y conciso.',
        )->prompt(
                $mensaje,
                model: <YOURIDLOCALMODAL>
            );
        return response()->json([
            'respuesta' => $response->text,
            'modelo' => 'gemma3:12b-it-IQ4_XS (Ollama)',
        ]);
    }
    /**
     * Generación de código
     */
    public function generarCodigo(Request $request): JsonResponse
    {
        $tipo = $request->input('tipo', 'migration de usuarios');
        $response = agent(
            instructions: 'Eres un experto en Laravel. Generas código limpio y sigues las mejores prácticas.',
        )->prompt(
                "Genera {$tipo} en Laravel 13. Solo dame el código, sin explicaciones.",
                model: <YOURIDLOCALMODAL>
            );
        return response()->json([
            'codigo' => $response->text,
            'solicitud' => $tipo,
        ]);
    }
    /**
     * Análisis de texto
     */
    public function analizar(Request $request): JsonResponse
    {
        $texto = $request->input('texto', 'Este producto es increíble, lo recomiendo totalmente');
        $response = agent(
            instructions: 'Eres un analizador de sentimientos. Respondes solo con positivo, negativo o neutro.',
        )->prompt(
                "Clasifica el siguiente texto: {$texto}",
                model: <YOURIDLOCALMODAL>
            );
        return response()->json([
            'sentimiento' => trim($response->text),
            'texto_original' => $texto,
        ]);
    }
}

Análisis de los nuevos ejemplos

1. Dinamismo con request()->input()

Para que esto sea una aplicación web real, necesitamos que el usuario interactúe. He configurado los métodos para que busquen un campo llamado texto en la petición. Si no existe, usamos un valor por defecto:

$input = $request->input('texto', '¿Qué sabes sobre Laravel?');

Esto nos permitirá conectar fácilmente nuestra lógica a un formulario de usuario en el futuro.

2. Agentes Anónimos y Análisis de Sentimientos

En estos ejemplos estamos utilizando Agentes Anónimos. Son agentes que definimos "al vuelo" con instrucciones específicas. Por ejemplo, para el analizador de sentimientos:

Instrucción: "Eres un experto analizando sentimientos. Responde solo: Positivo, Negativo o Neutro".

Entrada: "¡Este producto es increíble, lo recomiendo!"

Resultado esperado: Positivo.

3. Generación de Código Personalizada

Aquí combinamos un prompt fijo con la entrada del usuario. Le indicamos a la IA que es un experto en Laravel 13 y que debe generar código sin explicaciones adicionales. 

$tipo = $request->input('tipo', 'migration de usuarios');
->prompt("Genera {$tipo} en Laravel 13. Solo dame el código, sin explicaciones.",***)

Si el usuario pide "migración de usuarios", el sistema genera el esquema de base de datos exacto.

Ejemplos de respuestas

http://larapackage.test/agents/chat

{"respuesta":"Laravel es un framework PHP de c\u00f3digo abierto dise\u00f1ado para facilitar el desarrollo de aplicaciones web siguiendo el patr\u00f3n MVC (Modelo\u2011Vista\u2011Controlador). Sus principales caracter\u00edsticas son:\n\n- **Eloquent ORM**: modelo activo y sencillo para interactuar con bases de datos.\n- **Rutas declarativas**: definici\u00f3n clara de URLs y controladores asociados.\n- **Middleware**: filtrado de peticiones HTTP antes de llegar a la l\u00f3gica de negocio.\n- **Blade**: motor de plantillas ligero que permite escribir vistas con sintaxis PHP limpia.\n- **Migrations & Seeders**: manejo estructurado del esquema de bases de datos y datos iniciales.\n- **Colas y eventos**: procesamiento as\u00edncrono y arquitectura basada en eventos.\n- **Seguridad**: protecci\u00f3n CSRF, hashing de contrase\u00f1as, autenticaci\u00f3n lista para usar.\n\nLaravel sigue el principio \u201cconvention over configuration\u201d, lo que acelera la productividad sin sacrificar flexibilidad. Es ideal tanto para proyectos peque\u00f1os como para aplicaciones empresariales complejas.","modelo":"gemma3:12b-it-IQ4_XS (Ollama)"}

http://larapackage.test/agents/generar-codigo

{"codigo":"```php\n<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    public function up(): void\n    {\n        Schema::create('users', function (Blueprint $table) {\n            $table->id();\n            $table->string('name');\n            $table->string('email')->unique();\n            $table->timestamp('email_verified_at')->nullable();\n            $table->string('password');\n            $table->rememberToken();\n            $table->timestamps();\n        });\n    }\n\n    public function down(): void\n    {\n        Schema::dropIfExists('users');\n    }\n};\n```","solicitud":"migration de usuarios"}

http://larapackage.test/agents/analizar

{"sentimiento":"positivo","texto_original":"Este producto es incre\u00edble, lo recomiendo totalmente"}

Observaciones finales sobre las respuestas

Recuerda que, aunque el prompt sea el mismo, las IAs son estocásticas; es decir, no siempre darán la misma respuesta exacta, lo cual es parte de la magia del procesamiento de lenguaje natural.

Si notas que la conexión tarda o falla ocasionalmente, suele ser por la carga de tu equipo (CPU/GPU) al procesar el modelo localmente y no un error de programación. ¡Ya tenemos los cimientos! En la próxima clase dejaremos de usar agentes anónimos para crear Agentes estructurados, con clases propias y una lógica mucho más robusta.

Hay muchos errores que pueden ocurrir como:

Illuminate\Http\Client\ConnectionException
vendor\laravel\framework\src\Illuminate\Http\Client\PendingRequest.php:1805
cURL error 28: Operation timed out after 60013 milliseconds with 0 bytes received (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for http://127.0.0.1:1337/v1/chat/completions
--
Error [400]: Unknown error

Agentes Estructurados: Más allá de los Agentes Anónimos

Hasta ahora hemos trabajado con agentes anónimos, que son la forma más rápida y sencilla de empezar. Sin embargo, en la documentación de Laravel AI SDK encontramos lo que podemos llamar "agentes con nombre" o clases de agentes. Estos nos permiten organizar la lógica de la IA de una manera mucho más profesional y reutilizable.

1. Estructura de un Agente

Al crear un agente (como el ejemplo de SalesCoach), nos encontramos con una estructura muy interesante:

  • Constructor: Permite pasar datos externos al agente.
  • Instrucciones (instructions): Define el comportamiento o rol de la IA.
  • Mensajes (messages): Un método interno para cargar conversaciones previas o contexto histórico. Esto es vital para que la IA no "comience de cero" en cada interacción.
  • Herramientas (tools): Permiten que la IA ejecute funciones programáticas (por ejemplo, generar un número aleatorio o consultar una API externa) para resolver tareas específicas.

Creación de Agentes vía CLI

Para generar estos agentes, el SDK nos ofrece dos comandos principales que definen cómo será la respuesta de la IA:

Agente Estándar:

$ php artisan make:agent SalesCoach

Crea una estructura básica con instrucciones, mensajes y herramientas, pero sin un formato de salida obligatorio.

Agente con Estructura (Schema):

$ php artisan make:agent SalesCoach --structured
<?php
namespace App\Ai\Agents;
use App\Ai\Tools\RetrievePreviousTranscripts;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
use Stringable;
class SalesCoach implements Agent, HasTools //Conversational
{
    use Promptable;
    public function __construct(public ?User $user = null) {}
    /**
     * Get the instructions that the agent should follow.
     */
    public function instructions(): Stringable|string
    {
        return 'You are a sales coach, analyzing transcripts and providing feedback and an overall sales strength score.';
    }
    /**
     * Get the tools available to the agent.
     *
     * @return Tool[]
     */
    public function tools(): iterable
    {
        ***
    }
    /**
     * Get the agent's structured output schema definition.
     */
     public function schema(JsonSchema $schema): array
     {
         return [
             'feedback' => $schema->string()->required(),
             'score' => $schema->integer()->min(1)->max(10)->required(),
        ];
     }
}

Este es el más interesante para nosotros, ya que implementa un Esquema.

n Laravel estamos acostumbrados a que todo esté fuertemente estructurado. Si consultas un modelo Post, sabes exactamente qué campos esperar. Si usas un método all(), esperas una colección. Con la IA, si no definimos un esquema, el formato de respuesta puede variar y romper nuestra aplicación.

¿Por qué usar respuestas estructuradas?

Al definir un esquema, obligamos a la IA a que su respuesta encaje exactamente en el formato que necesitamos (generalmente JSON). Esto es excelente porque:

  • Integración: Puedes mapear la respuesta de la IA directamente a un componente de Livewire o a un controlador.
  • Consistencia: Si tienes una aplicación de planetas, puedes exigir que la IA siempre devuelva: nombre, descripción, diámetro y densidad.
  • Seguridad: Te aseguras de que tu código pueda procesar la data sin errores de formato inesperados.

El ejemplo Pokémon

Para este ejemplo, he configurado un agente experto en Pokémon. La instrucción es clara: "Eres un experto en Pokémon, genera datos realistas en formato JSON exacto". Aunque el SDK permite definir la estructura mediante código, siempre es buena práctica reforzar el formato deseado en el prompt.

Esta parte es fundamental porque funciona de forma muy similar a las validaciones de formularios en Laravel, garantizando la integridad de los datos.

routes\web.php

// Structured Output: Lista de Pokemon con PokemonAgent
Route::get('/agents/pokemon-lista', [AgentsTestController::class, 'listaPokemones']);

app\Http\Controllers\AgentsTestController.php

public function listaPokemones(Request $request): JsonResponse
{
    $resultado = (new PokemonAgent)->prompt(
        'Genera una lista de 3 Pokemon diferentes',
        model: <YOURIDLOCALMODAL>
        timeout: 120
    );
    // dd($resultado);
    return response()->json([
        'resultado' => $resultado->toArray(),
        'esquema' => [
            'pokemones' => [
                '*' => [
                    'nombre' => 'string',
                    'tipo' => 'enum',
                    'tamano' => 'integer',
                ],
            ],
        ],
    ]);
}

El agente queda como:

app\Ai\Agents\PokemonAgent.php

<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Stringable;
/**
 * Agente para generar lista de Pokemon con schema estructurado.
 *
 * Schema devuelto:
 * - pokemones: array de objetos
 *   - nombre: string
 *   - tipo: enum (fuego, agua, electrico, planta, normal, volador, psiquico)
 *   - tamano: integer (10-500 cm)
 */
// php artisan make:agent SimpleAgent --structured
class PokemonAgent implements Agent, HasStructuredOutput
{
    use Promptable;
    public function instructions(): Stringable|string
    {
        return 'Eres un experto en Pokemon. Generas listas de Pokemon realistas en formato JSON exacto.';
    }
    public function schema(JsonSchema $schema): array
    {
        return [
            'pokemones' => $schema->array()->items(
                $schema->object([
                    'nombre' => $schema->string()->required(),
                    'tipo' => $schema->string()->enum([
                        'fuego',
                        'agua',
                        'electrico',
                        'planta',
                        'normal',
                        'volador',
                        'psiquico',
                        'roca',
                        'tierra',
                    ])->required(),
                    'tamano' => $schema->integer()->min(10)->max(500)->required(),
                ])
            )->required(),
        ];
    }
}

Para asegurar que la IA nos devuelva exactamente lo que nuestra aplicación necesita, utilizamos los métodos internos de validación del SDK. La estructura se define así:

  • Nombre: Tipo string y obligatorio (required).
  • Tipos: Una lista de valores permitidos (fuego, agua, eléctrico, etc.).
  • Tamaño: Un valor entero (integer) con un rango mínimo y máximo definido.

Ejemplo de Generador de Cuestionarios (Q&A)

La instrucción principal de este agente es: "Genera preguntas y respuestas basándote en el contenido de los posts; genera preguntas de tipo Verdadero o Falso". Para asegurar que la salida sea procesable, definimos un esquema (Schema) con tres campos obligatorios:

  • Pregunta: El enunciado del cuestionamiento.
  • Respuesta: El valor booleano (Verdadero/Falso).
  • Explicación: El razonamiento detrás de la respuesta para ayudar al estudiante.

routes\web.php

// Quiz Verdadero/Falso basado en Posts (versión simple con prompt)
Route::get('/agents/quiz-simple', [AgentsTestController::class, 'quizPostsSimple']);

Podemos pasarle la cantidad de preguntas que deseamos (por defecto 5) y una lista de identificadores (IDs). Para procesar los IDs, utilizamos la función explode de PHP, que convierte un string separado por comas (ej. 1,2,3) en un array, permitiéndonos usar el método whereIn de Eloquent.

app\Http\Controllers\AgentsTestController.php

public function quizPostsSimple(Request $request): JsonResponse
{
    $ids = $request->input('ids'); // 1,2,3
    $cantidad = $request->input('cantidad', 5);
    $posts = Post::when($ids, fn($query) => $query->whereIn('id', array_filter(explode(',', $ids))), fn($query) => $query->where('posted', 'yes')->limit(1))
        ->get(['title', 'content']);
    if ($posts->isEmpty()) {
        return response()->json(['error' => 'No hay contenido'], 404);
    }
    
    $contenido = $posts->map(fn($post) => "Título: {$post->title}\nContenido: {$post->content}")->join("\n\n---\n\n");
    $prompt = "Basándote en los siguientes posts, genera {$cantidad} preguntas de verdadero o falso:\n\n{$contenido}";
    $resultado = (new QuizAgent)->prompt(
        $prompt,
        model: <YOURIDLOCALMODAL>
    );
    return response()->json([
        'quiz' => $resultado->toArray(),
        'posts_utilizados' => $posts->count(),
    ]);
}

Una vez obtenido el post, extraemos el título y el cuerpo del artículo para alimentar al agente. Es importante distinguir entre la instrucción del agente (su rol general) y la tarea específica (el prompt actual), que en este caso le indica cuántas preguntas generar exactamente basándose en el texto proporcionado.

El agente queda como:

app\Ai\Agents\QuizAgent.php

<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Stringable;
/**
 * Agente para generar quiz de Verdadero/Falso basado en posts.
 *
 * Schema devuelto:
 * - preguntas: array de objetos
 *   - pregunta: string
 *   - respuesta: boolean (true = verdadero, false = falso)
 *   - explicacion: string
 */
class QuizAgent implements Agent, HasStructuredOutput
{
    use Promptable;
    public function instructions(): Stringable|string
    {
        return 'Eres un generador de preguntas de quiz. Basándote en el contenido de los posts, generas preguntas de verdadero o falso. Las preguntas deben ser claras y la respuesta correcta debe estar basada en el contenido.';
    }
    public function schema(JsonSchema $schema): array
    {
        return [
            'preguntas' => $schema->array()->items(
                $schema->object([
                    'pregunta' => $schema->string()->required(),
                    'respuesta' => $schema->boolean()->required(),
                    'explicacion' => $schema->string()->required(),
                ])
            )->required(),
        ];
    }
}

Ejemplo de salida:

  • Pregunta: "¿Laravel Volt es un entorno de ejecución para PHP?"
  • Respuesta: "Falso".
  • Explicación: "Laravel Volt es una API funcional para Livewire que permite escribir lógica y plantillas en un mismo archivo".

La importancia del Esquema (Schema)

Aquí llegamos a lo que mencionas como el "talón de Aquiles" de la IA en el desarrollo de software: la falta de estructura.

En Laravel estamos acostumbrados a que todo esté fuertemente estructurado. Si consultas un modelo Post, sabes exactamente qué campos esperar. Si usas un método all(), esperas una colección. Con la IA, si no definimos un esquema, el formato de respuesta puede variar y romper nuestra aplicación.

¿Por qué usar respuestas estructuradas?

Al definir un esquema, obligamos a la IA a que su respuesta encaje exactamente en el formato que necesitamos (generalmente JSON). Esto es excelente porque:

  • Integración: Puedes mapear la respuesta de la IA directamente a un componente de Livewire o a un controlador.
  • Consistencia: Si tienes una aplicación de planetas, puedes exigir que la IA siempre devuelva: nombre, descripción, diámetro y densidad.
  • Seguridad: Te aseguras de que tu código pueda procesar la data sin errores de formato inesperados.

Ya sabes cómo generar estos agentes y por qué la estructura es la clave para integrar la inteligencia artificial en el flujo de trabajo de un desarrollador. No siempre querremos un simple texto por pantalla; la mayoría de las veces necesitaremos datos que nuestra aplicación pueda entender y procesar.

El Agente de Quiz: Del ejemplo a la utilidad real

Quiero profundizar en el agente de "Quiz" que implementamos anteriormente. Más allá de los ejemplos teóricos que ya vimos, quiero que lo analicemos desde un punto de vista funcional y realista. Mi propósito es que entiendas cómo adaptar un esquema similar para cualquier otra tarea que necesites en tus propios proyectos.

Para recapitular: este agente tiene una tarea fija, que es generar preguntas y respuestas en un formato específico. En esta ocasión, no estoy usando el proyecto del curso, sino uno propio donde estoy migrando mi aplicación actual a un entorno limpio. He decidido integrar este generador de quizzes porque, como saben, mis libros y cursos están íntimamente relacionados (relación uno a uno); por lo tanto, todo el contenido escrito es material perfecto para evaluar al alumno.

El problema de la creación manual

Crear laboratorios o cuestionarios manualmente (por ejemplo, para Udemy) es un verdadero fastidio. Requiere mucho tiempo pensar qué preguntas encajan mejor en cada sección, redactar las opciones correctas e incorrectas y darles variedad. Por falta de tiempo, muchas veces no los incluyo, y es precisamente aquí donde la IA se convierte en una herramienta invaluable para resolver un problema real de mi flujo de trabajo.

Implementación técnica con Livewire y PHP

En esta implementación utilizo Livewire. Para llamar al método que genera el quiz, utilizo un wire:click desde la vista. Si no dominas Livewire aún, no te preocupes (lo veremos más adelante); por ahora, piensa en esto simplemente como un formulario que, al hacer submit, ejecuta una función en el servidor escrita en PHP:

public function generarQuiz()
    {
        // $sections = BookSection::where('book_id', $this->model->id)->where('posted', 'yes')->where('orden', '>', 0)->get();
        $sections = BookSection::where('book_id', $this->model->id)->where('posted', 'yes')->where('orden', 4)->get();
        foreach ($sections as $s) {
            $resultado = (new QuizAgent)
***
<flux:button size="xs" variant="danger" wire:click='generarQuiz'>
     <flux:icon name="trash" />
</flux:button>            

La ventaja principal aquí es la integración total. A diferencia de otros enfoques donde podrías usar una herramienta externa o un asistente aislado (como los de GPT personalizados o herramientas CLI), aquí todo ocurre dentro de la misma aplicación. No necesitamos conectar bases de datos externas ni exportar archivos; el código PHP lee el capítulo del libro directamente de la base de datos y se lo pasa al agente.

Análisis del contenido y contexto

El contenido es extenso ya que, son capítulos ENTEROS de mis libros. Hacer 30 o 40 preguntas de este contenido manualmente podría tomar una hora por capítulo. Con el agente, simplemente le paso el texto completo y él se encarga del trabajo pesado.

Ventajas sobre el modelo de asistentes externos

  • Antes de llegar a esta solución, yo utilizaba un esquema mucho más rudimentario:
  • Extraía la información de la base de datos.
  • La pegaba en un asistente local (usando Jan.ai o LM Studio).
  • Pedía la salida en JSON.
  • Convertía ese JSON a SQL para insertarlo en mi base de datos.

Este flujo era horrible y fallaba constantemente por límites de contexto o respuestas incompletas. La integración directa en Laravel elimina estas ambigüedades. Al tener el agente encapsulado, puedo pasarle "super-prompts" que incluyen el contenido íntegro del capítulo sin salir de mi entorno de desarrollo.

Pruebas en el mundo real y rendimiento local

Actualmente estoy probando este sistema con un LLM local. Es importante mencionar que, aunque tengo una Mac potente, intentar ejecutar modelos muy pesados (como uno de 31B parámetros) puede provocar errores 500 sin descripción clara, simplemente porque el sistema se queda sin memoria o excede los tiempos de espera:

DeepSeek Error [500]: server_error - Compute error.
LARAVEL
13.4.0
PHP
8.4.19

Para estas prácticas, he configurado Gemma 3 12B y estoy evaluando los resultados. Fíjate que el proceso puede tardar (por eso es ideal usar sistemas de Colas y Jobs en Laravel), pero el resultado es coherente:

Agente de IA + Colas de Trabajo

Vamos a llevar nuestro agente al siguiente nivel mediante el uso de colas de trabajo (Queues). El motivo es sencillo: las IA suelen ser lentas en responder, especialmente cuando dependemos de servicios de terceros o procesos locales pesados.

Laravel + IA

Lo bonito de tener Laravel como un framework extremadamente robusto, que nos permita hacer de todo, junto con la IA, es que en este caso no vamos a usar Laravel como el bus para desarrollar, sino como una herramienta para llamar y hacer algo. En este caso, tal cual lo estamos haciendo desde la clase anterior, recuerda que lo estoy ligando; es el mismo agente, solo que aquí le llamé de otra manera, que es “trozos”. Ya te voy a explicar por qué.

Laravel nos ofrece un framework robusto para manejar estas tareas en segundo plano, permitiendo que la IA trabaje como una herramienta externa sin bloquear el hilo principal de nuestra aplicación.

El Desafío Técnico: Procesamiento Masivo de Datos con IA

En un entorno de desarrollo real, el problema principal no es solo conectar con la IA, sino cómo gestionar el flujo de información de manera efectiva. Mi caso de uso actual es un claro ejemplo: necesito generar una gran cantidad de preguntas y respuestas basadas en el contenido teórico de mis libros.

Aunque este flujo puede adaptarse a mil escenarios distintos, la constante es la misma: tienes una gran cantidad de data que debe ser procesada sin comprometer la estabilidad de la aplicación.

1. El Riesgo de Bloqueo en la Interfaz

No es viable procesar estas peticiones directamente en el hilo principal. Si intentamos llamar a la IA de forma síncrona, la aplicación se "colgará" inevitablemente durante segundos o incluso minutos. Esto genera una experiencia de usuario deficiente y puede provocar errores de timeout en el navegador o en el servidor.

2. Eficiencia y Escalabilidad

Para que el procesamiento sea verdaderamente efectivo, debemos implementar una arquitectura que permita:

  • Desacoplar la petición del procesamiento: El usuario solicita la generación y la aplicación responde de inmediato, mientras el trabajo pesado ocurre en segundo plano.
  • Gestión de colas de trabajo: Utilizar herramientas como las colas de Laravel para administrar las tareas, permitiendo que se ejecuten una tras otra de forma organizada.
  • Segmentación (Chunking): Dividir la información en trozos manejables para evitar saturar la memoria del servidor o los límites de contexto de la IA.

El Problema: El Hilo Principal Bloqueado

Anteriormente, llamábamos al agente directamente desde una clase de Livewire. Al hacerlo mediante un wire:click, el navegador y la aplicación se quedaban "colgados" esperando la respuesta de la IA.

En un entorno real, si necesitas procesar grandes volúmenes de datos (como generar cientos de preguntas basadas en libros), no puedes permitir que la interfaz se congele. Para solucionar esto, migramos la lógica a un Job de Laravel.

Gestión de Carga y Uso de IAs Locales

Para que nuestro sistema sea estable, no basta con enviar tareas a la cola; debemos ser estratégicos con el consumo de recursos. Si saturas tu máquina, el sistema operativo (ya sea Windows o macOS) podría cerrar los procesos por falta de memoria o exceso de carga, lo que interrumpiría la generación de tus contenidos.

1. La Estrategia del "Goteo" de Tareas

La recomendación principal es evitar el envío masivo de trabajos de golpe. Es mucho más efectivo pasar un grupo pequeño de tareas y, a medida que los Workers de Laravel se desocupen, enviar el siguiente lote.

En mi caso, estoy procesando fragmentos de mis libros. En lugar de intentar que la IA "lea" todo el libro a la vez, le paso fragmentos específicos directamente en el mensaje.

2. Formato de Entrada: El Contexto en el Prompt

Actualmente estamos usando el método más tradicional: incluir todo el contenido teórico directamente en la introducción del prompt.

  • Ventaja: Es directo y fácil de implementar al inicio.
  • Limitación: El tamaño del prompt tiene un límite (ventana de contexto). Si el fragmento es muy grande, la IA puede perder precisión o simplemente fallar.

3. Implementación con IAs Locales

Una de las grandes ventajas de este flujo es la posibilidad de usar modelos locales. Esto es ideal para trabajar con datos delicados o simplemente para ahorrar costes de API.

  • Ollama: Es la opción más robusta y "oficial" para integrar modelos locales en aplicaciones actualmente.
  • Alternativas: Aunque existen herramientas excelentes como Jan.ai o LM Studio, la integración directa mediante librerías suele estar más optimizada para Ollama.

4. ¿Por qué usar IAs Locales?

El uso de modelos locales (como Llama 3 o Mistral) nos permite realizar pruebas ilimitadas sin coste adicional. Solo debemos asegurarnos de que nuestra PC tenga la potencia necesaria (especialmente en GPU) para responder en tiempos aceptables.

Además de manejar TODOS los datos de manera privada

Estructura de la Cola de Trabajo (Job)

El Job se encarga de recibir fragmentos de contenido (segmentados en chunks) para que la IA no colapse. Estos son los puntos clave de la configuración:

  • Tiempo de Ejecución: He configurado un timeout alto (por ejemplo, 900 segundos) y el método set_time_limit(0) para evitar que PHP o el sistema de colas maten el proceso prematuramente.
  • Intentos (Tries): Es vital manejar excepciones. Si la IA falla por carga de trabajo, el sistema reintentará la tarea automáticamente.
    • public $timeout = 900;
      public $maxExceptions = 3;
      public $memory = 512;
      public $tries = 3;
    • Además de otros parámetros como la cantidad de memoria asignada.
  • Segmentación del Contenido: No le pasamos el libro entero. Dividimos el texto en fragmentos de unos 1,500 caracteres. Esto asegura que modelos locales (como Gemma 3 12B vía Jan.ai u Ollama) puedan procesar la información eficazmente.
    • foreach ($sections as $s) {
         $chunks = mb_str_split($s->content, 15000, 'UTF-8');
         foreach ($chunks as $content) {
            GenerateChunkQuizJob::dispatch(

Definición del Esquema JSON y el Modelo de Datos

Para que la respuesta de la IA sea útil, debe ser estructurada. He actualizado el esquema para que devuelva un formato tipo "Trivia" (estilo Duolingo):

  • Pregunta: El texto del desafío.
  • Opciones: Un array estrictamente de 4 opciones.
  • Respuesta Correcta: El índice de la opción válida y una explicación de por qué es correcta o incorrecta.
$resultado = (new QuizAgent)
    ->prompt(
        "
        Actúa como un profesor de programación. 
        Lee este fragmento de mi libro: '{$content}'
        Genera un examen de {$this->numQuestions} preguntas basado únicamente en ese texto.
        Responde SOLO con JSON válido, sin texto adicional ni markdown.
        Estructura requerida:
        {
            \"preguntas\": [
            {
                \"text\": \"pregunta\",
                \"options\": [\"opción1\", \"opción2\", \"opción3\", \"opción4\"],
                \"options_message\": [\"mensaje1\", \"mensaje2\", \"mensaje3\", \"mensaje4\"],
                \"correct_answer_index\": 0,
                \"language\": \"spanish\"
            }
            ]
        }
        Me lo das en {$this->language}
    ",
        model: $this->modelAi
    );
public function schema(JsonSchema $schema): array
{
    return [
        'preguntas' => $schema->array()->items(
            $schema->object([
                'text' => $schema->string()->required(),
                'options' => $schema->array()->items(
                    $schema->string()
                )->min(4)->max(4)->required(),
                'options_message' => $schema->array()->items(
                    $schema->string()
                )->min(4)->max(4)->required(),
                'correct_answer_index' => $schema->integer()->required(),
                'language' => $schema->string()->enum(['english', 'spanish'])->required(),
            ])
        )->required(),
    ];
}

La migración queda como:

Schema::create('learning_questions', function (Blueprint $table) {
    $table->id();
    $table->text('text');
    // $table->enum('question_type', ['multiple_choice', 'code'])->default('multiple_choice');
    $table->json('options'); // Almacena las opciones como un array JSON
    $table->json('options_message'); // Almacena las opciones como un array JSON
    $table->unsignedInteger('correct_answer_index');
    $table->enum('language', ['english', 'spanish'])->default('spanish');
    // Asumiendo que book_sections y tutorial_sections son otras tablas
    $table->foreignId('book_section_id')->nullable()->constrained('book_sections')->onDelete('cascade');
    $table->foreignId('tutorial_section_id')->nullable()->constrained('tutorial_sections')->onDelete('cascade');
    $table->timestamps();
});

Lo interesante de este sistema es la sinergia que logramos entre un modelo de Eloquent y las respuestas generadas por la IA. Al utilizar campos de tipo JSON en nuestras migraciones de base de datos, eliminamos la fricción de tener que mapear cada campo individualmente, permitiendo que la data fluya directamente desde la respuesta del modelo de lenguaje hacia nuestra aplicación.

1. El Uso de Campos JSON en las Migraciones

  • En el modelo Learning, hemos definido la estructura de la tabla con columnas JSON. Esto nos ofrece una flexibilidad enorme:
  • Persistencia Directa: Guardamos el objeto tal cual lo devuelve la IA (tras una validación previa).
  • Facilidad de Procesamiento: Laravel convierte automáticamente estos campos en arrays o colecciones de PHP, facilitando su uso en el frontend o en la lógica de negocio.

2. Vinculación entre Laravel y la IA

La clave aquí es la relación directa. No estamos simplemente recibiendo texto plano; estamos integrando la capacidad de razonamiento de la IA con la robustez de Laravel.

  • El flujo es limpio: enviamos un capítulo de un libro como contexto.
  • La IA genera las preguntas y opciones bajo un esquema predefinido.
  • Laravel recibe, valida e inserta el resultado en la base de datos.

3. Automatización del Flujo de Datos

El objetivo final es la automatización total. A diferencia de un proceso manual donde tendrías que copiar y pegar cada pregunta, aquí la IA actúa como un procesador de datos inteligente.

  • Verificación de Calidad: Aunque la IA hace el trabajo pesado, el sistema incluye una capa de validación para asegurar que la respuesta sea un JSON válido y que cumpla con los requisitos antes de la inserción.
  • Consistencia: Al atar el prompt a un capítulo específico del libro, garantizamos que las respuestas no sean "inventadas" al azar, sino que estén estrictamente fundamentadas en el contenido teórico proporcionado.

Este enfoque transforma a Laravel en algo más que un framework de desarrollo web; lo convierte en un motor de gestión de conocimiento. Al delegar la creación de contenido estructurado a la IA y la gestión de datos a Eloquent, podemos generar plataformas educativas masivas con un esfuerzo manual mínimo.

Validación y "Limpieza" de la Respuesta

No podemos confiar ciegamente en que la IA devuelva un JSON perfecto. Por ello, implementamos una lógica de validación:

  • Extract JSON: Un método para limpiar la respuesta de la IA y extraer solo el bloque de código JSON, eliminando texto adicional.
  • Verificación de Integridad: Comprobamos que el JSON sea decodificable y que contenga todos los campos requeridos antes de insertarlo en la base de datos.
  • Logs de Error: Si la decodificación falla, el error se registra en storage/logs/laravel.log para depuración.
if (! $jsonText) {
    \Log::error('GenerateQuizJob: No JSON found in response', [
        'book_section_id' => $this->bookSection->id,
        'response' => mb_substr($responseText, 0, 1000),
    ]);
    return;
}
$data = json_decode($jsonText, true);
if (json_last_error() !== JSON_ERROR_NONE) {
    \Log::error('GenerateQuizJob: Invalid JSON after extract', [
        'book_section_id' => $this->bookSection->id,
        'json_error' => json_last_error_msg(),
    ]);
    return;
}
if (! isset($data['preguntas'])) {
    \Log::warning('GenerateQuizJob: No preguntas key in response', [
        'book_section_id' => $this->bookSection->id,
        'keys' => array_keys($data ?? []),
    ]);
    return;
}
foreach ($data['preguntas'] as $pregunta) {
    LearningQuestion::create([
        'text' => $pregunta['text'],
        'options' => $pregunta['options'],
        'options_message' => $pregunta['options_message'] ?? [],
        'correct_answer_index' => $pregunta['correct_answer_index'],
        'language' => $pregunta['language'],
        'book_section_id' => $this->bookSection->id,
    ]);
}

Ejecución en Tiempo Real

Para poner todo en marcha, ejecutamos el comando:

$ php artisan queue:work

Los códigos completos quedan como:

app\Jobs\GenerateChunkQuizJob.php

<?php
namespace App\Jobs;
use App\Ai\Agents\QuizAgent;
use App\Models\BookSection;
use App\Models\LearningQuestion;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\SerializesModels;
class GenerateChunkQuizJob implements ShouldQueue
{
    use Queueable, SerializesModels;
    public $timeout = 900;
    public $maxExceptions = 3;
    public $memory = 512;
    public function __construct(
        public BookSection $bookSection,
        public string $contentChunk,
        public int $numQuestions = 3,
        public string $modelAi = 'gemma-3-12b-it-IQ4_XS',
        public string $language = 'español',
    ) {}
    public function handle(): void
    {
        $resultado = (new QuizAgent)
            ->prompt(
                "
                Actúa como un profesor de programación. 
                Lee este fragmento de mi libro: '{$this->contentChunk}'
                Genera un examen de {$this->numQuestions} preguntas basado únicamente en ese texto.
                Responde SOLO con JSON válido, sin texto adicional ni markdown.
                Estructura requerida:
                {
                  \"preguntas\": [
                    {
                      \"text\": \"pregunta\",
                      \"options\": [\"opción1\", \"opción2\", \"opción3\", \"opción4\"],
                      \"options_message\": [\"mensaje1\", \"mensaje2\", \"mensaje3\", \"mensaje4\"],
                      \"correct_answer_index\": 0,
                      \"language\": \"spanish\"
                    }
                  ]
                }
                Me lo das en {$this->language}
            ",
                model: $this->modelAi
            );
        $responseText = trim((string) $resultado);
        $jsonText = $this->extractJson($responseText);
        if (!$jsonText) {
            \Log::error('GenerateChunkQuizJob: No JSON found in response', [
                'book_section_id' => $this->bookSection->id,
                'response' => mb_substr($responseText, 0, 1000),
            ]);
            return;
        }
        $data = json_decode($jsonText, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            \Log::error('GenerateChunkQuizJob: Invalid JSON after extract', [
                'book_section_id' => $this->bookSection->id,
                'json_error' => json_last_error_msg(),
            ]);
            return;
        }
        if (!isset($data['preguntas'])) {
            \Log::warning('GenerateChunkQuizJob: No preguntas key in response', [
                'book_section_id' => $this->bookSection->id,
                'keys' => array_keys($data ?? []),
            ]);
            return;
        }
        foreach ($data['preguntas'] as $pregunta) {
            LearningQuestion::create([
                'text' => $pregunta['text'],
                'options' => $pregunta['options'],
                'options_message' => $pregunta['options_message'] ?? [],
                'correct_answer_index' => $pregunta['correct_answer_index'],
                'language' => $pregunta['language'],
                'book_section_id' => $this->bookSection->id,
            ]);
        }
    }
    private function extractJson(string $text): ?string
    {
        $text = trim($text);
        if (preg_match('/```json\s*([\s\S]*?)```/', $text, $matches)) {
            return trim($matches[1]);
        }
        if (preg_match('/\{[\s\S]*\}/', $text, $matches)) {
            return trim($matches[0]);
        }
        return null;
    }
}

app\Ai\Agents\QuizAgent.php

<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Stringable;
/**
 * Agente para generar preguntas de quiz con opciones múltiples.
 *
 * Schema devuelto:
 * - preguntas: array de objetos
 *   - text: string
 *   - options: array de 4 strings
 *   - options_message: array de 4 strings
 *   - correct_answer_index: integer (0-3)
 *   - language: enum ('english', 'spanish')
 */
class QuizAgent implements Agent, HasStructuredOutput
{
    use Promptable;
    public function instructions(): Stringable|string
    {
        return 'Eres un generador de preguntas de quiz con opciones múltiples. Genera preguntas con 4 opciones teórica. Las opciones válidas se guardan en "options" como array JSON. Los mensajes de verdadero/falso o éxito/error se guardan en "options_message" como array JSON. La opción correcta se indica en "correct_answer_index" (0-3) y el idioma en "language".';
    }
    public function schema(JsonSchema $schema): array
    {
        return [
            'preguntas' => $schema->array()->items(
                $schema->object([
                    'text' => $schema->string()->required(),
                    'options' => $schema->array()->items(
                        $schema->string()
                    )->min(4)->max(4)->required(),
                    'options_message' => $schema->array()->items(
                        $schema->string()
                    )->min(4)->max(4)->required(),
                    'correct_answer_index' => $schema->integer()->required(),
                    'language' => $schema->string()->enum(['english', 'spanish'])->required(),
                ])
            )->required(),
        ];
    }
}
public function generateQuiz()
{
    $sections = BookSection::where('book_id', $this->model->id)
        ->where('posted', 'yes')
        ->where('orden', '>', 0)
        ->get();
    foreach ($sections as $s) {
        $chunks = mb_str_split($s->content, 15000, 'UTF-8');
        foreach ($chunks as $content) {
            GenerateChunkQuizJob::dispatch(
                $s, 
                $content, 
                3, 
                'gemma-3-12b-it-IQ4_XS',
                'español'
            );
        }
    }
    Session::flash('status', 'Tareas almacenadas en la cola para generar las preguntas del libro');
}

Otras consideraciones

Posiblemente tengas que modificar otros parámetros en el servidor en tu php.ini como:

  • max_execution_time: Probablemente esté en 60. Cámbialo a 0 (infinito) o 900.
  • memory_limit: Si el modelo de IA o el procesamiento del texto consume mucha RAM, el proceso muere. Ponlo al menos en 512M.

Todos estos cambios son para evitar errores como:

NFO  Processing jobs from the [default] queue.  
App\Jobs\GenerateQuizJob ..... RUNNING
App\Jobs\GenerateQuizJob ..... 1m FAIL
zsh: killed     php artisan queue:work

Que puedes notar que el trabajo se detiene automáticamente pasado un minuto o un tiempo fijo; recordemos que las IAs tardan mucho tiempo en responder, y si ha esto le sumamos el prompt y la tarea que es pesada, hay que hacer un equilibrio entre los recursos de nuestro computador con la tarea que vamos a realizar.

El Potencial de las Tools en Laravel SDK IA

Video thumbnail

Las Tools son mecanismos extremadamente potentes que, al combinarse con agentes y sistemas de recuperación de conocimiento (como el RAG), nos permiten crear asistentes personalizados de alto nivel. Aunque herramientas como OpenAI o Claude son la referencia, implementar nuestras propias soluciones nos da un control total sobre tareas rutinarias y la privacidad de los datos.

Con este ecosistema podemos gestionar:

  • Conversaciones persistentes: Guardar y recuperar contextos previos.
  • Multimodalidad: Generar imágenes, audios y transcripciones.
  • Conectividad: Permitir que el agente interactúe con el mundo exterior o con nuestra propia infraestructura.

¿Qué son las Tools y por qué las necesitamos?

Tal como indica su nombre, las Tools son herramientas que dotan de funcionalidad extra a nuestros agentes. Podríamos verlas como un punto intermedio entre un agente básico y un sistema MCP (Model Context Protocol).

El problema de los LLM aislados

La mayoría de los Modelos de Lenguaje (LLM), especialmente los locales, fallan cuando se les pregunta información en tiempo real (clima, fecha actual, noticias). Al no estar conectados a la red por defecto, su conocimiento está limitado a su entrenamiento previo.

Ejemplo de uso de una Tool:

Si habilitamos una herramienta como WebSearch, el agente puede consultar internet para responder qué día es hoy o cuál es el clima actual. Sin esa "herramienta", el agente simplemente admitiría su incapacidad o alucinaría la respuesta:

app\Ai\Agents\Assistant.php

class Assistant implements Agent, HasTools
{
    use Promptable;

    public function instructions(): Stringable|string
    {
        return 'You are a helpful assistant. For URL requests, summarize the content provided by the user.';
    }

    public function tools(): iterable
    {
        return [
            new WebFetch,
        ];
    }
}

En este ejemplo ES UN TOOL EXISTENTE en Laravel pero, podemos crear los propios.

WebFetch: Permite que el agente busque en Google o Bing para obtener contexto actualizado.

Implementación Técnica en Laravel

Laravel facilita la creación de herramientas personalizadas mediante comandos artesanales.

Creación de una Tool

Para crear una nueva herramienta, utilizamos el comando:

$ php artisan make:tool NombreDeLaTool

Una vez creada, la estructura principal se define en el método handle, que es donde ocurre la magia. Aquí definimos la lógica que el agente ejecutará cuando decida que necesita esa herramienta específica.

Ejemplos prácticos de Tools:

  • Calendario y Citas: Un asistente personal puede usar una Tool para leer o escribir en un archivo (o base de datos) y agendar reuniones.
  • Comunicación Externa: Se puede crear una Tool que use la API de Telegram para que el agente envíe mensajes automáticamente al finalizar una tarea.

Caso Práctico: Agente de Citas (AppointmentAgent)

Un ejemplo donde separamos la lógica en tres piezas: el Comando, el Agente y la Tool.

  • La Tool (CreateAppointment): Define un esquema estructurado (fecha, mensaje, fecha de creación). Se encarga de verificar si el archivo existe en el storage y registrar la información de manera persistente.
  • El Agente: Recibe la instrucción del usuario y decide invocar la herramienta de citas para guardar la información.

app\Ai\Tools\ScheduleAppointment.php

<?php

namespace App\Ai\Tools;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

class ScheduleAppointment implements Tool
{
    public function description(): Stringable|string
    {
        return 'Schedule an appointment by storing the date and message in a JSON file within the storage/app directory.';
    }

    public function handle(Request $request): Stringable|string
    {
        $data = [
            'date' => $request['date'],
            'message' => $request['message'],
            'created_at' => now()->toIso8601String(),
        ];

        $filename = storage_path('app/appointments/'.now()->format('Y-m-d_His').'.json');

        if (! is_dir(dirname($filename))) {
            mkdir(dirname($filename), 0755, true);
        }

        file_put_contents($filename, json_encode($data, JSON_PRETTY_PRINT));

        $relativePath = 'appointments/'.now()->format('Y-m-d_His').'.json';

        return "Appointment scheduled successfully. Date: {$data['date']}, Message: {$data['message']}. Stored in: {$relativePath}";
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'date' => $schema->string()->required()->description('The appointment date and time in any format.'),
            'message' => $schema->string()->required()->description('A brief description or note about the appointment.'),
        ];
    }
}

app\Ai\Agents\Appointment.php

<?php

namespace App\Ai\Agents;

use App\Ai\Tools\ScheduleAppointment;
use Laravel\Ai\Attributes\Model;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
use Stringable;

// #[Provider(Lab::Ollama)]
// #[Model('gemma3:1b')]
class Appointment implements Agent, HasTools
{
    use Promptable;

    public function instructions(): Stringable|string
    {
        return 'You are an appointment scheduling assistant. When the user provides a date and message, use the ScheduleAppointment tool to save it. Call the tool immediately with the provided date and message.';
    }

    public function tools(): iterable
    {
        return [
            new ScheduleAppointment,
        ];
    }
}

app\Console\Commands\Ai\AppointmentCommand.php

<?php

namespace App\Console\Commands\Ai;

use App\Ai\Agents\Appointment;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;

#[Signature('ai:appointment {action?}')]
#[Description('Schedule appointments or list them')]
class AppointmentCommand extends Command
{
    public function handle(): int
    {
        $action = $this->argument('action') ?? $this->choice(
            'Action',
            ['schedule', 'list'],
            'schedule'
        );

        if ($action === 'list') {
            $this->listAppointments();

            return Command::SUCCESS;
        }

        $date = $this->ask('Appointment date (e.g., 2026-05-15 14:00)');
        $message = $this->ask('Message');

        $this->info('Scheduling appointment...');

        $response = (new Appointment)->prompt("Schedule an appointment. Date: {$date}, Message: {$message}", model: 'gemma-3-12b-it-IQ4_XS');

        $this->info('Response:');
        $this->line($response->text);

        return Command::SUCCESS;
    }

    private function listAppointments(): void
    {
        $files = glob(storage_path('app/appointments/*.json'));

        if (empty($files)) {
            $this->warn('No appointments found.');

            return;
        }

        $this->table(['Date', 'Message', 'Created'], collect($files)->map(function ($file) {
            $data = json_decode(file_get_contents($file), true);

            return [$data['date'] ?? '-', $data['message'] ?? '-', $data['created_at'] ?? '-'];
        })->all());
    }
}

Para usar el agente anterior:

$ php artisan ai:appointment

>> action [schedule]:
>>   [0] schedule
>>   [1] list
  • schedule , te permite registrar una cita en local.
  • list, permite ver las citas actuales.

Limitación en Entornos Locales

Es importante mencionar que, al trabajar con LLM locales (como los gestionados a través de Jan.ai u Ollama), el soporte para el uso de Tools puede ser limitado. En mis pruebas, aunque el código es correcto, algunos modelos locales aún no logran "decidir" cuándo llamar a la herramienta de forma automática, a diferencia de modelos en la nube como GPT-4 o Gemini.

Conclusión

Las Tools transforman a un simple chatbot en un operador funcional capaz de interactuar con archivos, APIs y bases de datos. Aunque actualmente existen retos con la integración en modelos locales, la arquitectura que ofrece Laravel nos deja listos para escalar nuestros agentes a un nivel de asistente personal real.

Introducción a los Embeddings y Bases de Datos Vectoriales

Video thumbnail

Hasta ahora, hemos trabajado con agentes que procesan trozos de texto para generar respuestas. Aunque funcional, este método tiene limitaciones: si el volumen de datos es muy grande, excedemos la "ventana de contexto" de la IA o requerimos un poder de cómputo inalcanzable para un entorno local.

El siguiente tópico que vamos a tratar es uno de los más interesantes en la documentación de Laravel AI: los Embeddings. Ya hemos visto cómo trabajar con agentes y el uso de contexto, pero los Embeddings llevan la IA a un nivel conceptual y técnico mucho más avanzado.

Los Embeddings (o incrustaciones) son la solución profesional. Un embedding transforma un fragmento de texto en un vector (una matriz numérica compleja que representa el significado semántico del texto). Esto nos permite realizar búsquedas inteligentes y gestionar grandes volúmenes de información mediante Bases de Datos Vectoriales.

El problema del contexto limitado

Recordemos que, al trabajar con agentes (como cuando generamos preguntas para un libro), el "contexto" es el trozo de información que le enviamos a la IA. Lo ideal sería enviarle el libro completo o un capítulo entero, pero nos enfrentamos a dos barreras:

  1. La ventana de contexto: Las IA tienen un límite de datos que pueden procesar a la vez.
  2. Poder de cómputo: Cuanta más data enviamos, más recursos consume. Al ejecutar modelos locales, esto puede colapsar nuestro equipo.

¿Qué es un Embedding y una Base de Datos Vectorial?

En términos sencillos, un Embedding es la conversión de un texto en un conjunto de vectores (un array de números). Cuando imprimimos un Embedding, vemos una lista larga de números decimales que representan el "significado semántico" de ese texto para la IA.

Para manejar esta información, no nos sirve una base de datos tradicional. Necesitamos una Base de Datos Vectorial.

  • Mientras que en SQL guardamos strings, integers o booleans, aquí manejamos un tipo de dato especial llamado vector.
  • Actualmente, la base de datos más robusta con soporte para esto es PostgreSQL mediante una extensión llamada pgvector.

Herramientas Necesarias

Para implementar esto, necesitamos tres componentes clave:

1. PostgreSQL con la extensión pgvector

A diferencia de MySQL o MariaDB, PostgreSQL cuenta con pgvector, la única opción robusta que permite manejar vectores nativamente.

Qué es: Es un tipo de dato especial, una "columna vectorial" que almacena estas matrices numéricas.

Instalación: Necesitas instalar PostgreSQL y habilitar la extensión ejecutando esta sentencia en tu base de datos; puedes usar Dbngin y crear una base de datos en PSQL desde la misma.

Una vez creada, ejecuta el comando de:

$ CREATE EXTENSION IF NOT EXISTS vector;

Y deberias ver algo como:

Query 1 OK: CREATE EXTENSION

2. Paquete PHP para Laravel

Instala el driver necesario para integrar PostgreSQL con las funciones de IA:

$ composer require pgvector/pgvector-php # O el paquete oficial de Laravel AI que requiera

Y configuramos la base de datos:

.env

DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=laravel
DB_USERNAME=postgres
DB_PASSWORD=postgres

3. Ollama (El "Cerebro" local)

Necesitamos un motor local que ejecute el modelo de lenguaje (LLM) encargado de realizar la conversión de texto a vector (Embedding). He elegido Ollama por su estabilidad, pero, puedes ir a la docmunentacion oficial (la tabla que copie antes para mostrar que LLM puedes emplear para que operaciones) y usar Gemino, OpenIA o cualquiera que soporte Embedding.

  • Descarga: ollama.com

Modelo recomendado: Usaremos un modelo específico para embeddings, ligero y eficiente:

$ ollama pull nomic-embed-text

Este modelo es pequeño, rápido y cuenta con una ventana de contexto de 8k tokens, ideal para procesar grandes volúmenes de datos.

Puede usar el comando de:

$ ollama list 
             
NAME                       ID              SIZE      MODIFIED   
nomic-embed-text:latest    0a109f422b47    274 MB    2 days ago    
andrescruz@Mac-mini-de-Andres larapackage % 

Para ver que está activo, y desde el navegador:

  • http://localhost:11434/

4. Configuración en Laravel

En tu archivo de configuración de IA (config/ai.php o similar), debes definir qué modelo usarás para el proceso de embedding:

Aunque ya tengas configurado un modelo para chat (como llama3 o gemma), para los embeddings puedes (y debes) especificar el modelo de Ollama:

'default' => 'openai',
***
'default_for_embeddings' => 'ollama',

¿Qué es una Columna Vectorial?

En una implementación tradicional, tenemos columnas normales: ID, texto, fechas. Sin embargo, en una base de datos vectorial aparece un tipo de dato especial: el vector:

Schema::create('documents', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('content');
    // $table->vector('embedding', dimensions: 1536)->index(); // OPEN IA
    $table->vector('embedding', dimensions: 768)->index(); // Ollama
    $table->timestamps();
});

Puedes imaginar un vector como una nube de puntos en un espacio multidimensional. Mientras que nosotros estamos acostumbrados a dos dimensiones (X, Y) o tres (X, Y, Z) los modelos de lenguaje (LLM) manejan cientos o miles de dimensiones.

  • OpenAI: Utiliza unas 1,536 dimensiones.
  • Nomic Embed Text (Local): Utiliza unas 768 dimensiones.

Si NO introduce las dimensiones correctas, recibirá un error como el siguiente:

SQLSTATE[22000]: Data exception: 7 ERROR: expected 1536 dimensions, not 768 (Connection: pgsql, Host: 127.0.0.1, Port: 5432, Database: laravel, SQL: update "documents" set "embedding" =

A la final, esta columna, cuando hagamos el proceso de embedding, veremos algo como:

use Laravel\Ai\Embeddings;
 
$response = Embeddings::for([
    'Napa Valley has great wine.',
    'Laravel is a PHP framework.',
])->generate();
 
$response->embeddings; // [[0.123, 0.456, ...], [0.789, 0.012, ...]]

Esta "matriz" de números es el formato que la IA entiende directamente. A diferencia de una base de datos normal donde puedes entrar y editar un texto a mano, un vector es una representación abstracta del significado de ese texto.

Advertencia: Gestión del Contexto y Límites de Datos

Cuando trabajamos con un LLM (Large Language Model), debemos recordar que la "ventana de contexto" no es infinita. Si intentas pasarle demasiada información de golpe, el sistema va a fallar. Por ejemplo:

Como en nuestro Seeder, donde tenemos apenas 5 registros con poco texto. Esto el modelo lo soporta perfectamente.

Escenario de riesgo: Si tuvieras 1,000 registros y cada uno con 10,000 caracteres. En este caso, el LLM fallará porque excederás el límite de tokens soportado, ya sea que trabajes con un modelo local o uno en la nube.

¿Cómo solucionarlo?

Si te encuentras en un caso de gran volumen de datos, la solución lógica es no enviar todo el bloque de golpe. Lo ideal es meter la lógica dentro de un bucle foreach e ir iterando los registros uno a uno (o en grupos pequeños), dependiendo de lo que el modelo soporte.

Es un detalle técnico fundamental que debes tener en mente al escalar tu aplicación para que no falle al procesar grandes cantidades de información.

Cercanía Semántica

Lo que realmente nos interesa para nuestros ejercicios es buscar la cercanía entre textos. Un modelo de IA, al ser entrenado con millones de datos, ya "sabe" qué palabras o conceptos están relacionados.

Por ejemplo, los términos "perro", "ladrido" y "veterinario" estarán representados por puntos muy cercanos en esa nube vectorial. En cambio, "perro" y "planeta" estarán muy alejados. Esta cercanía se mide matemáticamente mediante algoritmos como la Similitud del Coseno o la Distancia Euclidiana.

  • Similitud (Semejanza del coseno): Se mide de 0 a 1. Si es 1, los textos son idénticos; si es 0, no tienen nada que ver.
  • Distancia (Distancia euclidiana): Se mide de 0 hacia arriba. Si la distancia es 0, los puntos son iguales.

Cómo se Procesan los Datos: Del Texto al Vector

Para convertir un texto en una matriz numérica que podamos almacenar en PostgreSQL, la IA sigue estos pasos:

  • Tokenización: Rompe el texto en trozos pequeños llamados tokens.
  • Procesamiento Neuronal: Los tokens pasan por capas de una red neuronal.
  • Salida: El modelo devuelve una lista de números decimales (el vector).

Es como una especie de "binario complejo". Aunque nosotros no podamos leerlo, para la IA es su forma de indexar el conocimiento para recuperarlo de manera extremadamente rápida mediante una consulta SQL.

Para generar esta matriz de dimensiones, debes usar Si o Si un LLM que haga el trabajo; es decir, PostgreSQL es el almacenamiento y el LLM es el cerebro que permite generar esta estructura.

Ahora, vamos a generar los datos de prueba:

database\seeders\DocumentSeeder.php

<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class DocumentSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $documents = [
            ['title' => 'Napa Valley Wineries', 'content' => 'Napa Valley is famous for its world-class wineries and premium wine production. The region offers tasting rooms, vineyard tours, and stunning views.'],
            ['title' => 'Laravel Tutorial', 'content' => 'Laravel is a PHP framework known for its elegant syntax and developer-friendly features. It provides routing, authentication, and caching out of the box.'],
            ['title' => 'React.js Guide', 'content' => 'React is a JavaScript library for building user interfaces. It uses a component-based architecture and virtual DOM for efficient rendering.'],
            ['title' => 'Wine Tasting Tips', 'content' => 'When tasting wine, look at the color, smell the aroma, and savor the flavor. Start with white wines and move to reds for the best experience.'],
            ['title' => 'PHP Best Practices', 'content' => 'PHP 8 introduced many new features like named arguments, attributes, and match expressions. Follow PSR standards for clean code.'],
        ];
        foreach ($documents as $doc) {
            DB::table('documents')->insert([
                'title' => $doc['title'],
                'content' => $doc['content'],
                'embedding' => null,
                'created_at' => now(),
                'updated_at' => now(),
            ]);
        }
    }
}

database\seeders\DatabaseSeeder.php

public function run(): void
{
    // User::factory(10)->create();
    // User::factory()->create([
    //     'name' => 'Test User',
    //     'email' => 'test@example.com',
    // ]);
    $this->call(DocumentSeeder::class);
}

Y ejecutamos:

$ php artisan migrate:fresh
$ php artisan db:seed 

Con los datos de pruebas anteriores, veras que NO se llena la columna embedding, la vectorial, ya que, esta no es contenido en plano, para llenarla, debemos de utilizar la IA que configuramos antes:

app\Http\Controllers\EmbeddingTestController.php

public function generateEmbeddings(): JsonResponse
{
    $documents = Document::all();
    $contents = $documents->pluck('content')->toArray();
    $response = Embeddings::for($contents)->generate();
    foreach ($documents as $index => $document) {
        $document->embedding = $response->embeddings[$index];
        $document->save();
    }
    return response()->json([
        'message' => 'Embeddings generated via AI SDK',
        'method' => 'php-ai-sdk',
        'count' => count($response->embeddings),
    ]);
}

routes/web.php

Route::get('/embeddings/generate', [EmbeddingTestController::class, 'generateEmbeddings']);

Un método de consulta para que veas los datos listados:

app\Http\Controllers\EmbeddingTestController.php

    /**
     * Muestra todos los documentos con sus embeddings
     */
    public function listDocuments(): JsonResponse
    {
        $documents = Document::all(['id', 'title', 'content', 'embedding']);
        return response()->json([
            'documents' => $documents,
            'count' => $documents->count(),
        ]);
    }

routes/web.php

Route::get('/embeddings/list', [EmbeddingTestController::class, 'listDocuments']);

Implementación en Laravel (pgvector)

En Laravel, tenemos dos formas de realizar estas consultas según la documentación oficial:

1. Método Directo (whereVectorSimilarTo)

Es la forma más rápida y optimizada. Laravel se encarga de generar el embedding de tu búsqueda automáticamente y te devuelve los resultados más parecidos. Es ideal para búsquedas rápidas de similitud.

app\Http\Controllers\EmbeddingTestController.php

   /**
     * Búsqueda vectorial similarity (usa el query como string - Laravel genera embedding automáticamente)
     */
    public function search(Request $request): JsonResponse
    {
        $query = $request->get('q', 'best wineries');
        $results = Document::query()
            ->whereVectorSimilarTo('embedding', $query, minSimilarity: 0.4)
            ->limit(10)
            ->get(['title', 'content']);
        return response()->json([
            'query' => $query,
            'results' => $results,
            'count' => $results->count(),
        ]);
    }

La ruta:

routes\web.php

Route::get('/embeddings/search', [EmbeddingTestController::class, 'search']);
Route::get('/embeddings/search-with-embedding', [EmbeddingTestController::class, 'searchWithEmbedding']);

2. Método Manual (Distancia y Similitud)

Si necesitas más control, puedes hacerlo paso a paso:

  • selectVectorDistance: Generas el vector de búsqueda.
  • whereVectorDistanceLessThan: Calculas la distancia entre ese vector y los que tienes en la base de datos.
  • orderByVectorDistance: Filtras por un umbral (por ejemplo, solo lo que tenga una similitud mayor a 0.8).

app\Http\Controllers\EmbeddingTestController.php

    /**
     * Búsqueda con embedding pre-generado (más eficiente para múltiples búsquedas)
     */
    public function searchWithEmbedding(Request $request): JsonResponse
    {
        $query = $request->get('q', 'best wineries');
        $queryEmbedding = Str::of($query)->toEmbeddings();
        $results = Document::query()
            ->select('*')
            ->selectVectorDistance('embedding', $queryEmbedding, as: 'distance')
            ->whereVectorDistanceLessThan('embedding', $queryEmbedding, maxDistance: 0.6)
            ->orderByVectorDistance('embedding', $queryEmbedding)
            ->limit(10)
            ->get(['title', 'content', 'distance']);
        return response()->json([
            'query' => $query,
            'query_embedding_dimensions' => count($queryEmbedding),
            'results' => $results,
        ]);
    }

La ruta:

routes\web.php

Route::get('/embeddings/search-with-embedding', [EmbeddingTestController::class, 'searchWithEmbedding']);

Este enfoque es más manual pero te permite, por ejemplo, mostrarle al usuario qué tan "cercana" fue la respuesta encontrada, devolviendo el valor de la distancia como una columna virtual.

Aprende a conectar Gemini, ChatGTP o un LLM en local desde tu app en Laravel, presentamos, instalación, uso, crear esquemas, casos de uso y configuración para proyectos reales.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.

Andrés Cruz

EN In english