Construyendo un asistente agéntico en Laravel con el SDK de PHP de Anthropic: herramientas, model lanes, prompt caching y medición por tenant
Un cuadro de chat no es un asistente. Un modelo que solo puede hablar de tu negocio es una página de ayuda muy cara. Lo que hace útil al asistente de Zenpresa es que puede hacer cosas: reservar la cita, enviar la factura, encontrar a los clientes inactivos, aplicar el tema. Cada una de esas acciones es una herramienta, y el diseño del contrato de herramientas resultó importar más que la elección del modelo, el prompt o cualquier otra cosa en la que invertimos tiempo.
Este artículo es el relato de ingeniería de ese asistente. Corre sobre Laravel 12 y el SDK oficial de PHP de Anthropic, vive dentro de la arquitectura de base de datos por tenant descrita en el artículo anterior, y tiene que ser seguro, barato y rápido para muchos negocios a la vez. Los prompts, los límites exactos y los identificadores de modelo se omiten deliberadamente; los patrones son lo importante.
El contrato de herramientas
Cada acción que el asistente puede realizar implementa una interfaz PHP. Ligeramente simplificada, con nombres inventados:
interface AssistantTool
{
/** Nombre en snake_case expuesto al modelo */
public function name(): string;
/** Prescriptiva: "Llama a esto cuando el usuario quiera…" */
public function description(): string;
/** JSON Schema de la entrada */
public function inputSchema(): array;
/** Módulo que debe estar habilitado para el tenant, o null */
public function moduleKey(): ?string;
/** ¿Es esta herramienta usable en este despliegue? */
public function isAvailable(): bool;
/** ¿Necesita esta acción confirmación humana explícita? */
public function requiresConfirmation(): bool;
/** ¿Puede este usuario ejecutarla? Se vuelve a comprobar en cada llamada. */
public function authorize(User $user): bool;
/** Devuelve summary, data, link y un diff opcional */
public function execute(array $input, User $user): array;
}
Dos cosas de esta forma han resistido a lo largo de docenas de herramientas.
Primero, el resultado es uniforme. Cada herramienta devuelve un summary corto legible por humanos (mostrado al usuario y devuelto al modelo), un array data (devuelto solo al modelo), un link profundo opcional dentro de la app, y un diff opcional con el estado antes/después. La UI de chat renderiza esos cuatro campos de la misma forma para cada herramienta, así que añadir una herramienta nunca implica tocar el frontend. El diff en particular ha sido la pieza de UI que más confianza genera en el producto: cuando el asistente cambia el precio de un servicio, el usuario ve el valor viejo y el nuevo lado a lado.
Segundo, la descripción es prescriptiva, no descriptiva. “Reserva una cita” es una descripción. “Llama a esto cuando el usuario quiera poner a un cliente específico en un horario específico para un servicio específico; llama primero a get_available_slots si la hora es incierta” es una instrucción. El segundo tipo produce una selección de herramientas dramáticamente mejor, y es la razón por la que nuestras descripciones de herramientas se leen como un runbook.
Un registro filtrado por suscripción
Las herramientas viven en un registro indexado por nombre. Cuando empieza una conversación, el registro construye la lista que verá el modelo filtrando por tres cosas: isAvailable() (¿está la plataforma configurada para ella?), moduleKey() (¿está ese módulo en la suscripción del tenant?) y authorize() para el usuario actual. Un tenant sin el módulo de marketing nunca ve una herramienta de “enviar campaña”. Un miembro del personal sin permisos de facturación nunca ve “crear factura”.
Esto no es solo orden. El modelo no puede llamar a una herramienta de la que nunca se le habló, y una herramienta de la que no se le habló no puede filtrar en su descripción que la funcionalidad existe. El registro es el único lugar donde se decide “qué puede hacer este usuario a través del asistente”, y lee los mismos feature flags y comprobaciones de permisos que usa el resto de la app.
Autorizar en cada llamada, del lado del servidor
El filtrado del registro ocurre una vez por conversación. La autorización ocurre en cada ejecución de herramienta, otra vez, en el servidor. Los permisos pueden cambiar a mitad de conversación, una conversación puede reanudarse horas después, y la transcripción del modelo no es fuente de verdad de nada.
Dentro de execute(), la entrada se valida con las mismas reglas de validación de Laravel que usa el controlador HTTP equivalente. No escribimos un validador aparte y más laxo para el asistente. Si la API dice que el precio de un servicio debe ser un entero no negativo en unidades menores, la herramienta dice lo mismo, y una ValidationException se convierte en un error estructurado que el modelo puede leer y corregir. El modelo nunca tiene autoridad; lleva una petición que el servidor evalúa exactamente como si hubiera venido de un clic en un botón.
Model lanes fijados por conversación
No todos los mensajes necesitan el mismo modelo. “¿Cuál fue mi día más ocupado la semana pasada?” necesita un modelo rápido y barato que sea bueno llamando herramientas correctamente. “Reescribe el texto del hero de mi página de inicio para que suene más cálido” necesita un modelo con más rango. A esto lo llamamos lanes (carriles): un carril estándar y un carril creativo, cada uno con su propio modelo y su propia configuración de razonamiento, definidos explícitamente en lugar de heredados de un valor por defecto.
El carril se elige una vez, al inicio de una conversación, mediante un pequeño clasificador sobre el primer mensaje. Después queda fijado durante toda la vida de la conversación. La razón es el prompt caching: los prefijos cacheados están acotados a un modelo, así que cambiar de modelo a mitad de conversación tira la caché y vuelve a facturar todo el system prompt y la lista de herramientas en el siguiente turno. Fijarlo significa que el segundo turno y todos los siguientes aciertan en la caché. El costo de estar ocasionalmente en el carril “equivocado” para un mensaje es mucho menor que el costo de un fallo de caché en cada mensaje.
Breakpoints de prompt caching que realmente aciertan
El prompt caching de Anthropic factura los tokens de entrada cacheados a una fracción de la tarifa normal, pero solo si el prefijo es idéntico byte a byte entre peticiones. La petición que enviamos está dispuesta de forma que las partes caras y estables van primero y las partes volátiles van al final:
- System prompt, idéntico para cada tenant.
- Definiciones de herramientas, generadas desde el registro, estables para un conjunto dado de módulos habilitados.
- Un breakpoint de caché con un time-to-live largo después de las herramientas.
- Contexto específico del tenant y la conversación hasta el momento.
- Un segundo breakpoint de caché con un time-to-live corto en la cola de mensajes.
El primer breakpoint es el que paga: cada tenant con el mismo conjunto de módulos habilitados comparte el mismo prefijo cacheado, porque nada antes de ese breakpoint es específico del tenant. El segundo breakpoint hace baratas las conversaciones multi-turno, porque cada turno solo añade los mensajes más nuevos después de un prefijo que se cacheó en el turno anterior.
La invariante “nada antes del primer breakpoint es específico del tenant” se impone con un test. Construye el prefijo para dos tenants distintos y afirma que los bytes son idénticos. Ha fallado exactamente una vez, cuando alguien puso el nombre del negocio en el system prompt para un tono “más amigable”, y atrapó la regresión antes de que la tasa de aciertos de caché cayera en producción.
Streaming y el bucle de herramientas en PHP
PHP no es el primer lenguaje al que la gente recurre para hacer streaming de un bucle de agente, y funciona bien. El endpoint del asistente devuelve una StreamedResponse de Laravel con el content type text/event-stream y escribe Server-Sent Events a medida que el iterador de streaming del SDK los produce. Los deltas de texto van directo al cliente. Cuando el modelo emite una llamada a herramienta, el bucle ejecuta la herramienta, añade el resultado a la transcripción y emite la siguiente petición, todo dentro de la misma respuesta HTTP.
El bucle está acotado. Un turno de conversación solo puede dar un número fijo de vueltas al bucle de herramientas antes de que el servidor se detenga y le diga al usuario lo que ha hecho hasta el momento. Sin ese límite, un modelo confundido con una herramienta que falla puede iterar indefinidamente a tu costa.
Dos cosas tuvieron que verificarse en lugar de asumirse. Primero, el reverse proxy no debe hacer buffering: la app envía la cabecera que desactiva el buffering del proxy, y probamos de extremo a extremo que los tokens llegaban de forma incremental al navegador, porque un proxy que hace buffering convierte el streaming en una pausa muy larga seguida de todo de golpe. Segundo, en el cliente, el EventSource nativo del navegador no puede enviar cabeceras ni cuerpo de petición, así que el frontend usa fetch con un reader de ReadableStream y parsea los frames SSE por su cuenta. Son unas cuarenta líneas de TypeScript y no han necesitado cambios desde entonces.
Medición por tenant
Cada petición registra tokens de entrada, tokens de entrada cacheados, tokens de salida y el modelo que los sirvió. El costo se calcula a partir de una tabla de precios y se guarda en micro-dólares como entero, porque la moneda en punto flotante a escala es la forma en que terminas con un proyecto de conciliación.
Los topes por tenant se expresan en tokens, no en dólares. Fue una decisión deliberada: si enrutamos una conversación a un modelo más barato, el tenant obtiene más turnos con la misma asignación, y el margen de la plataforma mejora al mismo tiempo. Un tope en dólares haría invisible el enrutamiento de modelos para el cliente; un tope en tokens lo convierte en una funcionalidad.
El último detalle de medición es el que más tardamos en hacer bien. Cuando un usuario cierra la pestaña a mitad de respuesta, el stream se aborta. Los tokens ya han sido generados y facturados por el proveedor. Así que el uso se persiste en un bloque finally que se ejecuta tanto si el stream se completó, falló o el cliente se desconectó, y el registro persistido lleva un flag que anota la desconexión. Antes de ese cambio, el medidor contaba de menos cada vez que los usuarios se impacientaban, es decir, a menudo.
Enseñarle al modelo lo que no tiene herramienta
El mejor reporte de bug que recibimos sobre el asistente vino de un cliente que escribió “reserva una sesión” y obtuvo, correctamente, una cita reservada. Salvo que su negocio llamaba a las sesiones de otra forma, y lo que quería decir era “añade un botón de Reservar una sesión a mi página de contacto”. El asistente tenía una herramienta para reservar y ninguna para editar esa página concreta, así que hizo lo que podía hacer.
La solución no fue otra herramienta. Fue una referencia de capacidades: un documento que el modelo lee y que describe cada herramienta que tiene, cada tarea para la que no tiene herramienta, y qué hacer en cada uno de esos casos (explicar, enviar al usuario a la página de configuración correcta o hacer una pregunta aclaratoria). El asistente ahora dice “no puedo editar esa página directamente, pero aquí tienes el enlace y esto es lo que hay que cambiar” en lugar de adivinar.
El documento se mantiene honesto mediante un test: enumera el registro y falla el CI si alguna herramienta registrada falta en la referencia. La documentación que un test impone es documentación que sigue siendo cierta.
Qué haríamos diferente
Empezar con el campo diff. Añadimos el diff antes/después al resultado de las herramientas después del lanzamiento, y es la funcionalidad que más aumentó la confianza. Debería haber estado en el contrato desde el primer día.
Fijar los lanes desde el principio. Al inicio reclasificábamos cada mensaje y veíamos cómo la tasa de aciertos de caché se mantenía obstinadamente baja. Fijarlos fue un cambio de una línea con un efecto desproporcionado en el costo.
Escribir la referencia de capacidades antes que las herramientas. Describir lo que el asistente no puede hacer obligó a pensar con más claridad sobre lo que debería hacer.
Medir en el finally desde el primer día. Cada token contado de menos es dinero que ya has gastado.
Si estás construyendo algo parecido, el artículo sobre el harness de evaluación de este sitio cubre cómo puntuar la selección de herramientas y la seguridad antes de desplegar, y la comparación del OpenAI Agents SDK con la Responses API es un contraste útil si estás eligiendo entre SDKs de proveedores. Todo lo relativo a límites de tasa y aplicación de presupuestos delante del modelo vive en el artículo de control de costos de LLM.
Siguiente en la serie: Seguridad de herramientas de agente: inyección de prompt, códigos de confirmación fuera de banda y claves de idempotencia para herramientas que mueven dinero
FAQ: asistentes agénticos en Laravel
¿Se puede construir un bucle de agente de IA con streaming en PHP?
Sí. La StreamedResponse de Laravel con el content type text/event-stream, alimentada por el iterador de streaming del SDK de PHP de Anthropic, entrega los deltas de tokens como Server-Sent Events. El bucle de herramientas corre dentro de la misma respuesta: ejecutar la herramienta, añadir el resultado, pedir la siguiente completion, hasta un límite fijo. Las dos cosas a verificar son que tu reverse proxy no haga buffering de la respuesta y que el cliente use fetch con un ReadableStream en lugar de EventSource, que no puede enviar cabeceras ni cuerpo.
¿Cómo se mantiene el prompt caching efectivo entre muchos tenants?
Ordena la petición de forma que todo lo estable e independiente del tenant vaya primero: el system prompt y las definiciones de herramientas, seguidos de un breakpoint de caché de larga duración. Pon el contexto del tenant y el historial de conversación después, con un breakpoint de corta duración en la cola. Fija el modelo para toda la conversación, porque los prefijos cacheados están acotados por modelo. Impón la invariante de “prefijo sin tenant” con un test que construya el prefijo para dos tenants y afirme la igualdad byte a byte.
¿Por qué limitar el uso de IA en tokens en lugar de dólares?
Porque premia el enrutamiento. Si un modelo más barato maneja una conversación, un tope en tokens le da al tenant más turnos con la misma asignación mientras el costo de la plataforma baja. Un tope en dólares oculta ese beneficio al cliente. Internamente, el costo se sigue registrando en micro-dólares enteros para la conciliación.
¿Cómo evitan que el modelo llame a herramientas que el usuario no tiene permitido usar?
Dos veces. El registro de herramientas filtra lo que el modelo puede ver por disponibilidad de la plataforma, los módulos habilitados del tenant y los permisos del usuario. Luego, cada ejecución de herramienta vuelve a ejecutar la autorización y aplica las mismas reglas de validación que el endpoint HTTP equivalente. La transcripción del modelo nunca se trata como evidencia de permiso.
¿Qué hace el modelo con tareas que no tienen herramienta?
Lee una referencia de capacidades que describe cada herramienta, cada tarea sin herramienta y la respuesta correcta para cada una: explicar, enlazar a la página de configuración relevante o preguntar. Un test de CI enumera el registro de herramientas y falla si alguna herramienta registrada falta en la referencia, así que el documento no puede desviarse del código.
Artículos Relacionados
Limitación de tasa y control de costos de API LLM: Gestiona presupuestos de tokens, throttling por clave y dashboards de costos
Evita que los costos de API LLM se disparen. Una guía práctica sobre limitación de tasa, presupuestos de tokens por usuario, caché de coincidencia exacta y dashboards de costos con un proxy open-source desplegable.
Base de datos por tenant en Laravel 12: cómo Zenpresa aísla cada negocio y distribuye módulos como paquetes de Composer
Por qué Zenpresa da a cada tenant su propia base de datos Postgres en Laravel 12, cómo los módulos son paquetes de Composer, y qué trade-offs y bugs sobreviven.
Checks de CI que los linters no pueden hacer: contratos de API, patrones móviles, ratchets de warnings y tests que fijan bugs de Stripe
Cuatro checks de CI que Zenpresa añadió tras bugs reales: contrato de API, patrones móviles en JSX, ratchet de warnings y tests que fijan bugs de Stripe.