Curso de Laravel
Laravel AI SDK: agentes de inteligencia artificial en tu aplicación
Por Víctor Peña · Publicado el
Hola, ¿cómo están? Llegamos al último módulo del curso, y al que más ha cambiado las cosas en los últimos años.
Hasta hace poco, meter inteligencia artificial en un proyecto PHP significaba llamar a una API con cURL, armar el JSON a mano y rezar para que el modelo devolviera algo parseable. Hoy Laravel tiene un SDK oficial que resuelve todo eso.
Y lo mejor: encaja con lo que ya sabes del framework. Los agentes son clases, la salida se valida contra un esquema y las herramientas se declaran como cualquier otra clase del proyecto.
¡Empecemos!
Instalar
composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate
Esa migración crea las tablas para el historial de conversaciones.
Y en el .env, la clave del proveedor que vayas a usar:
ANTHROPIC_API_KEY=...
OPENAI_API_KEY=...
GEMINI_API_KEY=...
El SDK habla con catorce proveedores distintos con la misma interfaz. Eso significa que cambiar de modelo o de proveedor es cambiar un parámetro, no reescribir la integración. Es la misma idea de las interfaces que vimos en la lección de inyección de dependencias, pero ya resuelta.
El primer agente
php artisan make:agent AsistenteClinica
<?php
namespace App\Ai\Agents;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
use Stringable;
class AsistenteClinica implements Agent
{
use Promptable;
public function instructions(): Stringable|string
{
return 'Eres el asistente de una clínica en Cochabamba. Respondes en español,
de forma breve y clara. Si no sabes algo, lo dices; nunca inventas
información médica ni das diagnósticos.';
}
}
Y se usa así:
$respuesta = (new AsistenteClinica)->prompt('¿Qué documentos necesito para mi primera cita?');
return (string) $respuesta;
Eso es todo. Sin cURL, sin armar JSON, sin parsear la respuesta.
Fíjate en las instrucciones: ahí es donde se define el comportamiento. Ese «nunca inventas información médica» no es decorativo — es la diferencia entre un asistente útil y un problema legal.
Elegir proveedor y modelo
$respuesta = (new AsistenteClinica)->prompt(
'Resume esta consulta...',
provider: Lab::Anthropic,
model: 'claude-sonnet-5',
timeout: 120,
);
Ese timeout importa. Una llamada a un modelo puede tardar bastante, y sin límite el usuario se queda esperando indefinidamente.
Salida estructurada: lo que hace esto usable
Aquí está la funcionalidad que convierte la IA en algo que puedes meter en un sistema de verdad.
El problema de pedirle texto a un modelo es que devuelve texto: a veces con viñetas, a veces con un párrafo introductorio, a veces en inglés. Guardar eso en la base de datos es imposible.
La salida estructurada resuelve eso obligando al modelo a responder con una forma concreta:
php artisan make:agent ClasificadorConsultas --structured
<?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;
class ClasificadorConsultas implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): Stringable|string
{
return 'Clasificas los mensajes que llegan al formulario de contacto de una clínica.';
}
public function schema(JsonSchema $schema): array
{
return [
'categoria' => $schema->string()
->enum(['cita', 'consulta_precio', 'reclamo', 'urgencia', 'otro'])
->required(),
'urgencia' => $schema->integer()->min(1)->max(5)->required(),
'resumen' => $schema->string()->required(),
'requiere_respuesta_humana' => $schema->boolean()->required(),
];
}
}
$resultado = (new ClasificadorConsultas)->prompt($mensaje->contenido);
$mensaje->update([
'categoria' => $resultado['categoria'],
'urgencia' => $resultado['urgencia'],
'resumen' => $resultado['resumen'],
]);
if ($resultado['urgencia'] >= 4) {
Notification::send($equipo, new MensajeUrgente($mensaje));
}
El resultado llega como un arreglo con las claves que definiste, no como un texto que hay que interpretar. Ese enum() garantiza que la categoría sea una de las cinco, así que puedes guardarla en una columna con confianza.
Es exactamente el mismo razonamiento que la validación de formularios: defines la forma esperada y el sistema la hace cumplir.
Estructuras anidadas
public function schema(JsonSchema $schema): array
{
return [
'resumen' => $schema->string()->required(),
'sintomas' => $schema->array()
->items(
$schema->object(fn ($schema) => [
'descripcion' => $schema->string()->required(),
'duracion_dias' => $schema->integer(),
])
)
->required(),
'metadatos' => $schema->object(fn ($schema) => [
'confianza' => $schema->string()->enum(['baja', 'media', 'alta'])->required(),
'idioma' => $schema->string()->required(),
])->required(),
];
}
Ese campo confianza es un patrón que recomiendo: haz que el modelo declare qué tan seguro está, y usa ese valor para decidir si la respuesta pasa directo o va a revisión humana.
Herramientas: darle acceso a tus datos
Un modelo no conoce tu base de datos. Las herramientas son la forma de que pueda consultarla.
php artisan make:tool BuscarDisponibilidad
<?php
namespace App\Ai\Tools;
use App\Models\Cita;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
class BuscarDisponibilidad implements Tool
{
public function description(): Stringable|string
{
return 'Busca los horarios disponibles de un doctor en una fecha determinada.';
}
public function schema(JsonSchema $schema): array
{
return [
'doctor' => $schema->string()->required(),
'fecha' => $schema->string()->required(),
];
}
public function handle(Request $request): Stringable|string
{
$ocupados = Cita::whereDate('fecha', $request['fecha'])
->whereHas('doctor', fn ($q) => $q->where('nombre', 'like', "%{$request['doctor']}%"))
->pluck('fecha')
->map->format('H:i');
$todos = collect(['08:00', '09:00', '10:00', '11:00', '15:00', '16:00', '17:00']);
$libres = $todos->diff($ocupados);
return $libres->isEmpty()
? 'No hay horarios disponibles ese día.'
: 'Horarios disponibles: ' . $libres->implode(', ');
}
}
Y se declara en el agente:
class AsistenteClinica implements Agent, HasTools
{
use Promptable;
public function tools(): iterable
{
return [
new BuscarDisponibilidad,
new ConsultarPrecios,
];
}
}
Ahora el asistente puede responder «¿tiene turno el doctor Rojas el jueves?» con datos reales, porque el modelo decide solo cuándo llamar a la herramienta y usa el resultado para armar la respuesta.
La advertencia de seguridad
Aquí conviene detenerse, porque es lo más importante de esta lección.
Una herramienta es código que el modelo puede ejecutar, y el modelo obedece al texto que le llega. Si ese texto viene de un usuario, un usuario puede intentar dirigir su comportamiento.
Tres reglas que aplico siempre:
Solo lectura, salvo que haya una muy buena razón. Una herramienta que consulta horarios es segura. Una que cancela citas puede cancelar la cita equivocada.
Filtra por el usuario autenticado dentro de la herramienta, nunca según lo que diga el modelo:
public function handle(Request $request): Stringable|string
{
$citas = auth()->user()->citas()->where(...)->get();
}
Si la herramienta acepta un paciente_id que viene del modelo, alguien puede pedirle los datos de otro paciente. Es la misma lógica de autorización de siempre: la restricción va en la consulta, no en la petición.
Nada irreversible sin confirmación humana. Borrar, cobrar, enviar. El modelo propone; la persona confirma.
Streaming
Esperar diez segundos con la pantalla en blanco se siente mal. El streaming muestra la respuesta según se genera:
Route::get('/asistente', function (Request $request) {
return (new AsistenteClinica)->stream($request->mensaje);
});
Y para procesar los eventos:
$stream = (new AsistenteClinica)->stream($mensaje);
foreach ($stream as $evento) {
// ...
}
O reaccionar al terminar:
return (new AsistenteClinica)
->stream($mensaje)
->then(function (StreamedAgentResponse $respuesta) {
Log::info('Tokens usados', ['uso' => $respuesta->usage]);
});
Ese usage es lo que conviene registrar desde el primer día, porque es lo que se factura.
Memoria de la conversación
Sin memoria, cada mensaje empieza de cero y el asistente no recuerda lo que se dijo hace dos líneas.
use Laravel\Ai\Concerns\RemembersConversations;
class AsistenteClinica implements Agent, Conversational
{
use Promptable, RemembersConversations;
}
$respuesta = (new AsistenteClinica)->forUser($usuario)->prompt('Hola, quiero una cita');
$conversacionId = $respuesta->conversationId;
$respuesta = (new AsistenteClinica)
->continue($conversacionId, as: $usuario)
->prompt('¿Y el jueves por la tarde?');
Y en el modelo User:
use Laravel\Ai\Concerns\HasConversations;
class User extends Authenticatable
{
use HasConversations;
}
$conversaciones = $usuario->conversations()->latest('updated_at')->paginate(20);
Ojo con el costo: cada mensaje reenvía el historial completo, así que una conversación larga cuesta cada vez más. En conversaciones que se alargan, conviene resumir lo antiguo en lugar de arrastrarlo entero.
Embeddings y búsqueda vectorial
Este es el caso de uso que más valor da en un sistema real, y el menos evidente.
Un embedding convierte un texto en un vector de números que representa su significado. Dos textos que dicen lo mismo con palabras distintas quedan cerca en ese espacio.
Eso permite algo que un LIKE '%...%' no puede: buscar por lo que la gente quiso decir, no por las palabras exactas que escribió.
$embeddings = Str::of('Los pacientes con seguro deben traer su carnet.')->toEmbeddings();
use Laravel\Ai\Embeddings;
$respuesta = Embeddings::for([
'Los pacientes con seguro deben traer su carnet.',
'El horario de atención es de 8 a 18.',
])->generate();
Guardarlos en la base de datos
Schema::ensureVectorExtensionExists();
Schema::create('documentos', function (Blueprint $tabla) {
$tabla->id();
$tabla->string('titulo');
$tabla->text('contenido');
$tabla->vector('embedding', dimensions: 1536)->index();
$tabla->timestamps();
});
protected function casts(): array
{
return ['embedding' => 'array'];
}
Buscar por significado
$documentos = Documento::query()
->whereVectorSimilarTo('embedding', '¿qué papeles llevo si tengo seguro?')
->limit(5)
->get();
Esa consulta encuentra el documento del carnet, aunque no comparta ni una palabra con la pregunta. Un LIKE no habría devuelto nada.
Con un umbral mínimo de parecido:
Documento::query()
->whereVectorSimilarTo('embedding', $consulta, minSimilarity: 0.4)
->limit(10)
->get();
Responder sobre tus propios documentos
Juntando las dos cosas, tienes un asistente que responde con la información de tu empresa:
public function responder(string $pregunta): string
{
$contexto = Documento::query()
->whereVectorSimilarTo('embedding', $pregunta, minSimilarity: 0.4)
->limit(5)
->get()
->pluck('contenido')
->implode("\n\n");
return (string) (new AsistenteClinica)->prompt(
"Responde usando únicamente esta información. Si no está aquí, dilo.\n\n"
. "INFORMACIÓN:\n{$contexto}\n\nPREGUNTA: {$pregunta}"
);
}
Ese patrón —buscar primero, responder después— es el que evita que el modelo se invente cosas. Y ese «si no está aquí, dilo» es la instrucción más importante del bloque.
Y como generar embeddings cuesta, conviene cachearlos:
Embeddings::for([$texto])->cache(seconds: 3600)->generate();
Todo esto a la cola
Una llamada a un modelo tarda segundos. Nunca la pongas en el camino de una petición web si puedes evitarlo:
class ClasificarMensaje implements ShouldQueue
{
use Queueable;
public int $timeout = 180;
public int $tries = 3;
public function __construct(public Mensaje $mensaje) {}
public function handle(): void
{
$resultado = (new ClasificadorConsultas)->prompt($this->mensaje->contenido);
$this->mensaje->update([
'categoria' => $resultado['categoria'],
'urgencia' => $resultado['urgencia'],
]);
}
}
Es la lección de colas aplicada al caso donde más falta hace. Y ese tries: 3 cubre los fallos temporales del proveedor, que existen.
Controlar el gasto
Algo que no se menciona lo suficiente: cada llamada cuesta dinero, y un error puede salir caro.
- Registra el consumo desde el primer día, con el
usagede cada respuesta. - Limita las peticiones por usuario, con lo que vimos en la lección de API.
- Usa el modelo más pequeño que sirva. Para clasificar mensajes no hace falta el modelo más caro.
- Cachea lo repetitivo. Las mismas preguntas frecuentes no necesitan una llamada nueva cada vez.
- Pon alertas de gasto en el panel del proveedor.
Errores comunes
- Llamadas al modelo en la petición web en vez de en cola.
- Pedir texto libre cuando necesitas datos estructurados.
- Herramientas que escriben sin confirmación humana.
- Filtrar por lo que dice el modelo en lugar del usuario autenticado.
- Conversaciones sin límite, que crecen en costo cada mensaje.
- No registrar el consumo.
- Confiar en la respuesta sin un mecanismo de verificación.
- La clave de la API en el repositorio.
Para cerrar
El Laravel AI SDK convierte la integración con modelos de lenguaje en algo que se parece al resto del framework: clases, esquemas, contratos, colas.
Lo que de verdad cambia el juego para un sistema de gestión son dos cosas: la salida estructurada, que hace que la respuesta sea guardable y confiable, y la búsqueda por embeddings, que permite buscar por significado dentro de tus propios documentos.
Y una advertencia que vale por toda la lección: el modelo obedece al texto que recibe, y ese texto muchas veces viene de un usuario. Trata las herramientas con el mismo cuidado con el que tratas cualquier entrada externa.
En la última lección del curso veremos el otro lado: usar la IA para desarrollar más rápido.
Saludos y éxitos.
Norvic Software
Desarrollamos el software que tu empresa necesita
Somos una fábrica de software en Bolivia. Construimos sistemas a medida y aplicaciones móviles, y llevamos Inteligencia Artificial a las empresas que ya tienen un sistema funcionando.
- Chatbots con Inteligencia Artificial
- Creación de agentes de IA
- Integración de IA en tus sistemas
- Desarrollo de software a medida
- Aplicaciones móviles iOS y Android
- Consultoría y asesoramiento técnico
Cotización sin costo · Respuesta directa por WhatsApp