Separar lógica determinista y decisiones LLM
Publicado por Lino Uruñuela el 2026-05-19
TL;DR — Durante las últimas semanas he medido cuántos tokens de pago necesito consumir para producir el mismo informe SEO ejecutado de dos formas distintas. Reescribir la skill como un script Python que solo invoca al LLM en 5 GATEs concretos bajó el coste de 8-10 € por auditoría a menos de 2 €, eliminó los pasos saltados por «eficiencia» del modelo y dejó cada run reanudable y reproducible meses después.

Las últimas semanas las he pasado probando distintas maneras de ejecutar la misma tarea SEO, no para responder a "¿qué modelo funciona mejor?" sino a "Para el mismo informe final, ¿cuántos tokens de pago necesito consumir?".
La conclusión fue simple: lo que estaba diseñando con tanto prompt no era una skill, sino un programa. Y los programas se escriben en código, no en markdown.
Ejemplo de una auditoría de datos estructurados
Pongamos como ejemplo una auditoría de datos estructurados de una URL. A grandes rasgos los pasos son:
-
Validar los datos estructurados de la URL usando la herramienta Rich Results Test de Google.
No sirve una validación que el propio LLM haga con su propio conocimiento. Muchas veces no coincidirá con lo que dice el validador de Google, porque el modelo no sabe qué tipos son aptos para resultados enriquecidos o porque solo valida que el formato JSON sea correcto o valide con schema.org. Lo que queremos es saber si la herramienta de Google Prueba de resultados enriquecidos (Rich Results Test, RRT) reconoce los datos estructurados de la URL son aptos para generar resultados enriquecidos y si alguno tienen errores o warnings.
-
Corregir los errores o warnings.
Lo ideal es que un LLM corrija lo detectado por RRT. Para eso hay que pasarle (a) el código actual, (b) los errores que reportó la herramienta y (c) la documentación oficial de Google sobre las propiedades afectadas para cada
@type. Con estos datos se genera un prompt que le pide al LLM el JSON-LD corregido. -
Validar el código generado por el LLM.
No nos fiamos de lo que devuelva: hay que volver a pasarlo por la herramienta de Google. Si vuelve a fallar, reenviamos al LLM lo del paso 2 más el código que acaba de probar y los errores nuevos, para que sepa qué probó antes y qué corrigió o rompió. El máximo de reintentos lo dejamos en 3.
-
Sugerir otros tipos de marcado.
Aquí queremos saber si hay
@typeadicionales que merece la pena añadir y no estamos usando. Una forma es mirar qué resultados enriquecidos aparecen en las SERPs relacionadas con la URL objetivo y a qué tipos están asociados. Otra manera complementaria es ver qué competidores aparecen con esos snippets y qué marcado usan. Los@typeque tienen ellos y nosotros no son la base para sugerencias de nuevos tipos.Para esto hay que saber qué buscar en Google, y eso se deriva del title, el H1 y los textos principales de la propia URL. Es decir, el flujo es: extraer on-page → decidir keywords → lanzar SERPs → identificar bloques enriquecidos → seleccionar competidores → extraer su marcado → cruzar con la URL objetivo → priorizar gaps → generar código corregido → validar → generar código nuevo → validar otra vez → redactar resumen.
El primer intento: una súper skill de 2.000 líneas
Al principio escribí una skill donde indicaba a Claude Code qué script podía usar para algunas subtareas, por ejemplo render_url.py para obtener el HTML, google_search_simple.py para lanzar una búsqueda, etc. Poco a poco fui creando pequeños scripts que resolvían subtareas que yo iba identificando como repetitivas e iba añadiendo a la documentación de la skill tanto el nuevo script como el modo de uso.
---
name: auditoria-marcado-datos
description: >
**WHEN**: auditoría y optimización de datos estructurados (JSON-LD /
Microdata / RDFa) de una URL. Opera con **scripts locales** - no
delega en subagentes. Flujo determinista: extracción
(`render_url.py` + `parse_schemas.py --json`), validación local
opcional (`validate_jsonld.py`), validación **oficial** vía
**Google Rich Results Test con sesión persistente**
(`check_rich_results_url_authenticated.py` + fallback por código
`check_rich_results_html_from_url_authenticated.py`)
**solo para la URL objetivo**, …
---
El fichero completo de la skill (reglas, formato de salida, condiciones de fallback, etc.) acabó cerca de las 2.000 líneas. Demasiado largo según las recomendaciones habituales sobre cómo escribir skills. Pero funcionaba: si lo ejecutabas con Claude Code para una URL, generaba un informe completo y correcto.
El problema era el coste: una ejecución completa sobre una URL ecommerce mediana se iba entre 8 y 10 €. Sí, tal cual, por ejemplo estos son parte de los comandos y procesos que Claude Code realizaba para esta skill:
Y este es el flujo, donde el propio LLM realizaba tareas extra además de las necesarias que yo iba definiendo. Por ejemplo tras una ejecución leía el resultado, con su coste añadido, comprobaba el número de líneas de un fichero o ejecutaba un comando en python importando librerías para corroborar que determinado dato estructurado tenía determinado elemento...

Además del coste, tenía otro problema, a veces el LLM se salta algún paso por diversos e imprevisibles motivos. Al ejecutarlo desde Claude Code es raro que falle (la skill ha pasado por muchas iteraciones ajustándose concretamente a Claude), pero aun así también ocurren errores. Con Codex falla bastante más y el informe sale incompleto o con datos inventados.
Una de las cosas que añadí a la skill es la regla de que cualquier código Python que el agente generara durante la ejecución lo guardara en un script propio bajo el directorio codigo-usado/. Así, en un futuro, podría revisar qué código se generaba en cada ejecución y detectar subtareas repetitivas para las que el modelo siempre escribía un script nuevo «al vuelo». Cuando identificaba una, creaba un script y añadía la regla de usarlo y cómo usarlo en la propia skill.
Además también iba añadiendo otras reglas para que el LLM dejase de hacer algunas cosas: por ejemplo, prohibir generar Python para extraer schemas de un HTML porque ya existía parse_schemas.py. Poco a poco las reglas e instrucciones de la skill se iban convirtiendo en algo más parecido a pseudocódigo que a otra cosa.
El cambio: scripts deterministas + LLM solo en GATEs
Así que creé una segunda versión para que hiciese lo mismo pero basado en la ejecución de scripts específicos para aquellas subtareas que se pudiesen automatizar. El script en Python (run_auditoria.py) se encarga de coordinar la ejecución del resto de scripts y delega en el LLM solo cuando hace falta tomar alguna decisión.
Este script utiliza la suscripción que tengo y uso en Claude Code directamente, así que el coste real es 0 €. Pero calculo y almaceno cuánto habría costado la ejecución usando la API para todo lo que ocurre en Claude Code, también desde los scripts, que realmente usan claude code mediante el comando claude -p.
La idea es que todo lo que se pueda ejecutar desde un script, que lo haga el script. Los pasos donde hace falta una decisión los hace el LLM. En este ejemplo, el LLM solo interviene en cinco puntos concretos, los GATEs:
- GATE 1: Selección de keywords SERP a partir los datos extraídos del HTML (title, H1, description,
@typesdeclarados). - GATE 2: Selección del TOP de competidores entre los dominios que aparecen en las SERPs capturadas.
- GATE 3: Corrección del JSON-LD de la URL objetivo para resolver los errores y warnings que la herramienta de Google devolvió. Bucle de hasta 3 repeticiones si sigue encontrando errores o warnings.
- GATE 4: JSON-LD sugerido que sugiere tipos de datos estructurados no presentes en la URL pero sí en competidores o en SERPs.
- GATE 5: Resumen.
El resto del informe se genera en base a scripts (renderizar HTML, parsear schemas, lanzar SERPs, generar pantallazos, ensamblar markdown). Al ejecutar todo desde un orquestador puedo indicar qué modelo usar en cada fase, por ejemplo: Haiku para las decisiones sencillas como selección de keywords, competidores y la generación de un resumen. Y usar el modelo Opus para decisiones que requieran más "inteligencia" como generar el JSON-LD corregido o la sugerencia de nuevos tipos de datos y su código. Además no solo puedo elegir el LLM de Claude a usar en cada decisión sino que también puedo ejecutar LLM locales, en mi caso uso gemma4:e4b y qwen3.5:9b.
El coste de la ejecución completa de esta forma baja claramente frente a los 8-10 € de la ejecución de la skill directamente en Claude Code.
Comparativa de los dos enfoques
Ejecución paso a paso
Al script orquestador se le pasa como argumento la URL a validar, el proyecto al que pertenece y una serie de parámetros: cuántas keywords analizar, cuántos competidores comparar y la fase (que por defecto es --step init, y es la que arranca el proceso completo). En el ejemplo de este artículo usé una URL de Zara:
url = https://www.zara.com/es/es/mujer-vestidos-fiesta-l1581.html
proyecto = STATE_OF_THE_ART
n_keywords = 3
n_comps = 3
El fichero donde se guardan los datos y rutas que se usarán
El primer paso es generar un timestamp único (2026-05-12_07-09-09) y un slug a partir de la URL (www-zara-com-es-es-mujer-vestidos-fiesta-l1581-html). Este prefijo se usará en el nombre de todos los ficheros del proceso para poder saber en cualquier momento de la ejecución (y posterior) qué salida corresponde a qué ejecución.
El fichero state.json contiene todo lo que en el resto de pasos se va a necesitar: la URL, el proyecto, los parámetros, las 26 rutas absolutas de los ficheros que se van a generar, el estado actual de la ejecución, el historial y los contadores de repeticiones.
Si un paso necesita saber dónde está el HTML obtenido de la herramienta de datos estructurados, lo lee del state.json. Si necesita saber qué número de reintentos lleva procesados la corrección de JSON-LD, lo lee del state.json. Si necesita saber qué competidores se eligieron, lo lee del state.json.
Eso permite tres cosas que para mí son fundamentales:
- Reanudación independiente de cada paso. Si el paso 11 peta por timeout del LLM, lo vuelves a lanzar sin tener que reejecutar del 1 al 10.
- Reconstrucción de cada run. Dentro de seis meses puedo abrir el directorio y saber qué decidió el LLM en cada fase, qué respondió la herramienta de Google, en qué reintento consiguió solucionar los errores, etc.
- Coste mínimo del agente. El modelo no necesita "recordar" todo lo que se ha hecho: en cada decisión recibe solo el contexto que necesita, nada más.
Validar la URL objetivo con Google Rich Results Test
El segundo paso es validar los datos estructurados de la URL objetivo con la herramienta de Google. El script accede a la URL de la herramienta usando una sesión persistente (un perfil de Chrome bajo un directorio concreto que mantiene la sesión Google del usuario), pega la URL y captura todo lo que la herramienta devuelve, y guarda en diferentes ficheros la información que usaremos en siguientes pasos:
Para Zara, el resultado inicial fue:
ok = false
tipos detectados = 4
tipos con errores = 1
tipos con warnings = 3
errores totales = 1
warnings totales = 10
Tipos detectados:
Warnings que la herramienta mostró:
- Falta
review. - Falta
aggregateRating. - Falta
availability. - Falta
description. - Falta
shippingDetails. - Falta
hasMerchantReturnPolicy. - Falta un identificador internacional como GTIN o marca.
- Falta
urlen el carrusel.
Qué ocurre cuando algo falla
El paso anterior no siempre funciona a la primera. Algunas URLs no son rastreables por Googlebot, otras tienen un meta robots noindex que impide a Google indexar la URL y genera errores en la tool de validación, etc.
Para esos casos en los que falla al validar los datos estructurados hay diferentes maneras de ejecutarlo:
--mode url: pega la URL en la herramienta de datos estructurados de Google, la ejecuta, y espera el resultado.--mode code-from-url: si el anterior falla o RRT detecta «no rastreable por Googlebot», el script renderiza la URL localmente con Chrome, elimina las líneas que contienen<meta name="robots" content="noindex">y pega ese HTML en la pestaña «Código» de la herramienta. Si el TSV de salida sigue vacío, reintenta usando User Agent de Googlebot.--mode payload: si todavía no hay salida válida, delega en el scriptrender_url.py, que tiene sus propios métodos alternativos para obtener el HTML.
Qué HTML usar como base
Después de la validación con la tool, el sistema tiene que decidir desde qué HTML extraerá el on-page y los datos estructurados que usará en el resto del análisis. El script ejecuta una cascada determinista de 4 niveles:
- Si existe el HTML que devolvió la propia tool de Google (el que vio el rastreador), usa ese.
- Si no, ejecuta
curlcon User Agent de Googlebot. - Si no,
curlcon UA de Chrome. - Como último recurso, navegador real (
shot-scrapercon Chromium) ejecutando JavaScript.
En el ejemplo de Zara, el HTML base salió de la propia tool, que es el código que Google vio al rastrear, que es justo lo que nos vale como SEOs: el código que Google ve y valora.
Extracción on-page, schemas y validación local
Con el HTML ya obtenido se lanzan tres scripts en paralelo:
extract_onpage.py
parse_schemas.py
validate_jsonld_batch.py
El primero extrae con regex y reglas título, meta description, headings, canonical, hreflangs, robots, Open Graph y Twitter cards. En el caso de Zara:
title = Vestidos de Fiesta | ZARA España
H1 = -
canonical = https://www.zara.com/es/es/mujer-vestidos-fiesta-l1581.html
hreflangs = 94 entradas
El segundo parsea los datos estructurados:
jsonld_count = 1
microdata itemtypes = BreadcrumbList, ListItem
El tercero hace una validación local del JSON-LD. Es una validación secundaria que solo sirve como detección barata de errores antes de pasar por Google.
Con esto conseguimos que el LLM no vea ni procese el HTML completo. El HTML de Zara renderizado pesa varios MB y hacer que modelo "busque los schemas" en ese HTML cuando extraerlos mediante un script es muy sencillo y rápido sería tirar el dinero.
El script devuelve un JSON ya filtrado con lo que el siguiente paso necesita: title, description, tipos detectados, warnings, errores y bloques relevantes.
Los cinco GATEs del pipeline
GATE 1: seleccionar keywords
Hasta aquí no ha intervenido el LLM, todo ha sido procesado y ejecutado por scripts, y cada salida generada está almacenada en el fichero correspondiente.
En este paso el LLM decide qué keywords usará para realizar las búsquedas en Google. Para ello generamos un prompt en el que añadimos datos de contexto de la URL:
- URL auditada.
- Dominio.
- Title.
- H1.
- Description.
- Tipos de schema detectados.
En el prompt también se indica el número de keywords que queremos que devuelva, este dato se lo pasamos como argumento, y algunas reglas como "no usar keywords brand (que contengan el nombre del dominio)", o no usar "KWs navegacionales", etc.
En este ejemplo el LLM seleccionó las siguientes keywords:
vestidos de fiesta
vestidos de fiesta mujer
vestidos fiesta negros
Obtener datos de SERPs
Con las keywords seleccionadas, otro script (google_search_simple.py) ejecuta cada búsqueda y, por cada una, genera tres ficheros:
Para Zara:
Esta información se guarda en un fichero llamado serp-blocks-…-2026-05-12_07-09-09.json, que será parte de la entrada del siguiente paso.
GATE 2: elegir competidores
Tras capturar las SERPs, el LLM vuelve a tomar una decisión, ahora debe elegir qué competidores analizar. Para ello se le pasa un JSON con los resultados de Google para cada una de las búsquedas, el tipo de resultado que es, y los propios datos estructurados de la URL examinada y se solicita que devuelva un número X que se le pasa también como argumento al inicio del proceso en este caso 3.
En el ejemplo de Zara seleccionó:
Renderizar competidores y extraer su marcado
Por cada competidor el sistema (a) renderiza el HTML, (b) extrae los datos estructurados con parse_schemas.py y (c) los valida en local. Cada paso genera un fichero (render-…html, schemas-…json, localValidation-…json).
Cruzar datos para descubrir tipos de datos potenciales
El script schemas_matrix.py recibe todos los datos disponibles (schemas de la URL objetivo, validación local de la URL, schemas de cada competidor, validaciones locales de competidores, JSONs de SERP, pantallazos de SERP, resumen de la validación RRT) y genera un fichero llamado matrix-…-2026-05-12_07-09-09.json con cinco tipos de datos:
En paralelo, se ejecuta el script schemas_doc_reference.py que consulta la documentación de Google previamente convertida a markdown y guardada en local, y añade la información correspondiente a los @type con errores o warnings detectados anteriormente.
Montar la plantilla del informe
Se crea la plantilla del informe y se deja lista para añadir las siguientes secciones:
- Todas las secciones generadas por scripts deterministas (tabla resumen, datos RRT del target, comparativa, SERPs, recomendaciones, referencias) quedan añadidas al informe.
- En los huecos donde luego irá el contenido del LLM, el script deja marcas identificables
<!-- gate-section-XX:open --> … <!-- gate-section-XX:close -->, que serán reemplazadas por sus secciones correspondientes en cuanto se generen.
GATE 3: corregir errores en el JSON-LD actual
En este paso el LLM genera el JSON-LD corregido para cada tipo de marcado de datos que tuvo errores o warnings. Para generar el JSON corregido se genera un prompt con la información obtenida de la validación del código de la URL examinada, la documentación de Google referente a los atributos de cada tipo de datos con errores o warnings y se añaden también algunas reglas como:
- Un fichero JSON por
@typeraíz (no todo en un único blob). - No recortar listas anidadas.
- No dejar warnings pendientes.
- No inventarse datos como si fueran reales (los inventados van marcados en el informe).
El proceso en caso de fallar volverá a intentarlo hasta tres veces:
- El LLM escribe los ficheros JSON corregidos por tipo.
build_jsonld_payload.pylos empaqueta en el HTML mínimo necesario para validar.- Ese HTML se valida en la herramienta de datos estructurados en modo "Código", es decir, se accede a la URL de la tool, se pega el HTML corregido en la pestaña de "Código" y se ejecuta.
- Si Google devuelve
errors=0 AND warnings=0, la validación fue correcta y anotastate.iter_final['fixed']con la iteración ganadora. - Si sigue mostrando errores o warnings y el número de reintentos es menor de 3, se vuelve al inicio de la fase GATE 3, al que se le añade como contexto al nuevo prompt el resultado de la validación anterior y el código usado.
- Si se agotan los 3 reintentos, se marca
state.residuals['fixed']=truey el informe deja la sección como residual no resuelto.
En el ejemplo de Zara el LLM generó dos ficheros:
breadcrumblist.json
itemlist.json
Y la validación cerró:
ok = true
errores totales = 0
warnings totales = 0
El LLM corrigió correctamente los datos estructurados que contenían error o warning (Fragmentos de productos, Fichas de comerciantes, Rutas de exploración y Carruseles) en una sola iteración.
Y es que no se debe confiar en que el LLM haya escrito bien el JSON-LD, se debe validar de nuevo el código generado usando la herramienta de Google para comprobar si es correcto.
GATE 4: sugerir marcado nuevo
Las condiciones para que se sugiera un nuevo JSON con algún tipo de dato estructurado que no está presente en la URL auditada son que:
- El
@typeaparece en al menos un competidor. - Está asociado a algún rich result visible en las SERPs capturadas.
- La URL objetivo tiene contenido real que respaldaría el marcado.
Antes de validar, se comprueba si un tipo concreto propuesto ya existe en el JSON-LD corregido de la fase GATE 3; si lo está, se descarta. No tiene sentido sugerir lo que ya tienes.
Este paso también se puede saltar (skip_suggested=true) si no se detectan gaps justificables. Es decir, el sistema sabe decir "no hay nada que sugerir" en vez de inventarse oportunidades por alucinación.
En Zara generó un único fichero:
image-object.json
Y el JSON sugerido validó a la primera:
Metadatos de imagen - 1 elemento válido
errores = 0
warnings = 0
GATE 5: resumen ejecutivo
El último GATE escribe un breve resumen.
El resultado:
El infomre completo lo podéis ver aquí
El infomre completo lo podéis ver aquí
Lecciones aprendidas
El ahorro no viene solo por usar modelos más baratos, que también. El ahorro viene de quitarle al LLM todo lo que no debería estar haciendo.
- Si una tarea puede resolverse con un script, no debería consumir tokens haciendo pensar a un modelo de pago.
- Si un proceso tiene pasos, estados, reintentos y salidas intermedias, probablemente no es una skill: es un programa o flujo de ejecución.
- El LLM funciona mejor cuando decide en puntos concretos, con contexto mínimo y entradas ya filtradas.
- Una auditoría generada por un agente no debería depender de que el modelo “recuerde” lo que hizo antes. El estado debe estar en ficheros.
- Y, sobre todo, no se debe confiar en que el LLM haya escrito bien el JSON-LD: se debe validar de nuevo con la herramienta de Google.
Para mí, ese es el cambio importante: pasar de pedirle a un LLM que ejecute una auditoría completa a diseñar un sistema donde el LLM solo entra cuando aporta algo que un script no puede resolver de forma determinista.

