JSON confiable: valida el esquema de la salida estructurada de tu modelo
"Devuelve solo JSON" es una de las instrucciones más comunes en aplicaciones LLM en producción — y una de las menos fiables. Los modelos envuelven JSON en cercas de markdown, añaden una frase amigable, o (si son modelos de razonamiento) narran todo su proceso de pensamiento alrededor de ello. Cualquiera de esos casos rompe un estricto JSON.parse, y tu pipeline se cae.
En esta guía construimos una evaluación de Promptfoo que hace que los modelos de API de Inferencia WEC clasifiquen tickets de soporte en JSON validado por esquema, luego lo endurecemos contra el desorden del mundo real — y lo usamos para elegir un modelo en el que realmente puedes confiar. Esta es la parte 2 de la serie de evaluaciones (consulta parte 1 para la configuración inicial). Todo aquí se ejecutó en vivo contra https://inference.wiline.com.
Cómo funciona la evaluación
Cada test pasa por la misma tubería. El detalle más importante: el transform se ejecuta
sobre la salida del modelo antes de que la aserción la vea — así es como recuperamos JSON
desordenado antes de validarlo.
Requisitos previos
-
Una clave de API de Inferencia WEC — consulta Inferencia → Claves de API.
-
Los nombres de los modelos utilizados aquí (
GLM-5.2,gemma4,Qwen2.5-3B-Instruct,qwen3.5:9B) provienen del Hub de Modelos — intercambia por lo que hayas habilitado. -
Node.js 22+ y tu clave exportada (Promptfoo se ejecuta a través de
npx):nvm use 24export WEC_API_KEY='sk-your-key'
Si tu primera ejecución da error con Cannot convert argument to a ByteString, tu clave pegada tiene un carácter invisible — consulta Solución de problemas al final.
Paso 1 — Un esquema estricto y un fallo real
Haremos algo más cercano a una carga de trabajo real que "capital de Francia": clasificar un ticket de soporte en categoría, prioridad y un breve resumen. La mejora clave sobre una verificación básica es el Esquema JSON — impone enums, por lo que un modelo que inventa una categoría falla.
cat > promptfooconfig.yaml <<'EOF'
description: "API de Inferencia WEC — salida estructurada (esquema JSON)"
providers:
- id: openai:chat:zai-org/GLM-5.2
config:
apiBaseUrl: https://inference.wiline.com/v1
apiKeyEnvar: WEC_API_KEY
temperature: 0
prompts:
- |
Clasifica este ticket de soporte. Devuelve SOLO JSON con claves:
"categoría" (una de: facturación, técnica, cuenta, otro),
"prioridad" (una de: baja, media, alta),
"resumen" (cadena, máximo 12 palabras).
Ticket: {{ticket}}
defaultTest:
assert:
- type: is-json
value:
type: object
required: [categoría, prioridad, resumen]
properties:
categoría: { type: string, enum: [facturación, técnica, cuenta, otro] }
prioridad: { type: string, enum: [baja, media, alta] }
resumen: { type: string }
tests:
- vars: { ticket: "Me cobraron dos veces por mi instancia este mes." }
assert: [{ type: icontains, value: facturación }]
- vars: { ticket: "Mi VM no arranca después de la última restauración de instantánea." }
assert: [{ type: icontains, value: técnica }]
EOF
npx -y promptfoo@latest eval -c promptfooconfig.yaml
El resultado — 50%, y el fallo es instructivo:
ticket [zai-org/GLM-5.2]
Me cobraron dos veces por mi instancia… [FAIL] ```json
{ "categoría": "facturación", "prioridad": "alta", … }
```
Mi VM no arranca después de la última… [PASS] { "categoría": "técnica", … }
✓ 1 aprobado (50%) ✗ 1 fallido (50%)
Figura 1. Mismo modelo, mismo aviso, temperatura 0 — una respuesta es limpia, la otra está envuelta en ```json cercas, por lo que is-json no puede analizarla.
El contenido es correcto; el formato no lo es. is-json valida tanto "¿es JSON analizable?" como "¿cumple con el esquema?" — y la salida cercada falla la primera prueba.
Paso 2 — Recuperar salida cercada con un transform
Un transform se ejecuta en la salida del modelo antes de las afirmaciones. Aquí eliminamos las cercas. (Este bloque utiliza cuatro comillas invertidas porque la configuración en sí contiene tres comillas invertidas.)
cat > promptfooconfig.yaml <<'EOF'
description: "API de Inferencia WEC — transformación de eliminación de cercas"
providers:
- id: openai:chat:zai-org/GLM-5.2
config:
apiBaseUrl: https://inference.wiline.com/v1
apiKeyEnvar: WEC_API_KEY
temperature: 0
prompts:
- |
Clasifica este ticket de soporte. Devuelve SOLO JSON con claves:
"categoría" (una de: facturación, técnica, cuenta, otro),
"prioridad" (una de: baja, media, alta),
"resumen" (cadena, máximo 12 palabras).
Ticket: {{ticket}}
defaultTest:
options:
transform: |
return output.replace(/```json\n?|\n?```/g, '').trim();
assert:
- type: is-json
value:
type: object
required: [categoría, prioridad, resumen]
properties:
categoría: { type: string, enum: [facturación, técnica, cuenta, otro] }
prioridad: { type: string, enum: [baja, media, alta] }
resumen: { type: string }
tests:
- vars: { ticket: "Me cobraron dos veces por mi instancia este mes." }
assert: [{ type: icontains, value: facturación }]
- vars: { ticket: "Mi VM no arranca después de la última restauración de instantánea." }
assert: [{ type: icontains, value: técnica }]
EOF
npx -y promptfoo@latest eval -c promptfooconfig.yaml --no-cache
Ahora ambos pasan — 2/2. Las cercas se eliminan antes de que is-json vea la salida.
Figura 2. El transform elimina las cercas de código, por lo que el ticket que falló anteriormente se analiza.
transform necesita un return explícitoUn transform de varias líneas es un cuerpo de función, no una expresión — omite return y obtienes Transform function did not return a value. Consulta Solución de problemas.
Paso 3 — La solución más limpia: modo JSON
En lugar de limpiar después del modelo, pide a la API que emita solo JSON válido a través de response_format. Sin transform esta vez:
cat > promptfooconfig.yaml <<'EOF'
description: "API de Inferencia WEC — modo JSON (response_format)"
providers:
- id: openai:chat:zai-org/GLM-5.2
config:
apiBaseUrl: https://inference.wiline.com/v1
apiKeyEnvar: WEC_API_KEY
temperature: 0
response_format: { type: json_object }
prompts:
- |
Clasifica este ticket de soporte. Devuelve SOLO JSON con claves:
"categoría" (una de: facturación, técnica, cuenta, otro),
"prioridad" (una de: baja, media, alta),
"resumen" (cadena, máximo 12 palabras).
Ticket: {{ticket}}
defaultTest:
assert:
- type: is-json
value:
type: object
required: [categoría, prioridad, resumen]
properties:
categoría: { type: string, enum: [facturación, técnica, cuenta, otro] }
prioridad: { type: string, enum: [baja, media, alta] }
resumen: { type: string }
tests:
- vars: { ticket: "Me cobraron dos veces por mi instancia este mes." }
assert: [{ type: icontains, value: facturación }]
- vars: { ticket: "Mi VM no arranca después de la última restauración de instantánea." }
assert: [{ type: icontains, value: técnica }]
EOF
npx -y promptfoo@latest eval -c promptfooconfig.yaml --no-cache
2/2 APROBADO sin transformación — parece que el modo JSON funciona.
Figura 3. Con response_format: json_object, GLM-5.2 devuelve JSON limpio, sin necesidad de transformación.
Pero antes de confiar en response_format, necesitamos probarlo en todos los modelos — porque un solo resultado verde puede ser suerte, no cumplimiento.
Paso 4 — Matriz de fiabilidad entre modelos WEC
Ejecuta la configuración en modo JSON contra los cuatro modelos de chat. Esta es la recompensa — una comparación de fiabilidad específica de WEC que no puedes obtener de una tabla de clasificación genérica.
cat > promptfooconfig.yaml <<'EOF'
description: "API de Inferencia WEC — fiabilidad en modo JSON entre modelos"
providers:
- id: openai:chat:zai-org/GLM-5.2
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0, response_format: { type: json_object } }
- id: openai:chat:gemma4
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0, response_format: { type: json_object } }
- id: openai:chat:Qwen2.5-3B-Instruct
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0, response_format: { type: json_object } }
- id: openai:chat:qwen3.5:9B
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0, response_format: { type: json_object } }
prompts:
- |
Clasifica este ticket de soporte. Devuelve SOLO JSON con claves:
"categoría" (una de: facturación, técnica, cuenta, otro),
"prioridad" (una de: baja, media, alta),
"resumen" (cadena, máximo 12 palabras).
Ticket: {{ticket}}
defaultTest:
assert:
- type: is-json
value:
type: object
required: [categoría, prioridad, resumen]
properties:
categoría: { type: string, enum: [facturación, técnica, cuenta, otro] }
prioridad: { type: string, enum: [baja, media, alta] }
resumen: { type: string }
tests:
- vars: { ticket: "Me cobraron dos veces por mi instancia este mes." }
assert: [{ type: icontains, value: facturación }]
- vars: { ticket: "Mi VM no arranca después de la última restauración de instantánea." }
assert: [{ type: icontains, value: técnica }]
EOF
npx -y promptfoo@latest eval -c promptfooconfig.yaml --no-cache
4/8. GLM-5.2 y Qwen2.5-3B pasan limpiamente; gemma4 y qwen3.5:9B vierten su razonamiento
("Proceso de Pensamiento…") en lugar de JSON — a pesar de que response_format esté configurado.
Figura 4. response_format configurado en los cuatro — pero los dos modelos orientados al razonamiento lo ignoran.
| Modelo | Resultado | Tokens (2 llamadas) |
|---|---|---|
zai-org/GLM-5.2 | ✅✅ | 202 |
Qwen2.5-3B-Instruct | ✅✅ | 243 |
gemma4 | ❌❌ (razonamiento) | 251 |
qwen3.5:9B | ❌❌ (razonamiento) | 1,757 |
Paso 5 — Verificar: ¿está response_format haciendo algo?
No asumas que el razonamiento fue causado por el modo JSON. Vuelve a ejecutar la misma matriz sin
response_format (usando la transformación de eliminación de cercas en su lugar) y compara:
cat > promptfooconfig.yaml <<'EOF'
description: "API de Inferencia WEC — matriz sin response_format"
providers:
- id: openai:chat:zai-org/GLM-5.2
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
- id: openai:chat:gemma4
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
- id: openai:chat:Qwen2.5-3B-Instruct
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
- id: openai:chat:qwen3.5:9B
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
prompts:
- |
Clasifica este ticket de soporte. Devuelve SOLO JSON con claves:
"categoría" (una de: facturación, técnica, cuenta, otro),
"prioridad" (una de: baja, media, alta),
"resumen" (cadena, máximo 12 palabras).
Ticket: {{ticket}}
defaultTest:
options:
transform: |
return output.replace(/```json\n?|\n?```/g, '').trim();
assert:
- type: is-json
value:
type: object
required: [categoría, prioridad, resumen]
properties:
categoría: { type: string, enum: [facturación, técnica, cuenta, otro] }
prioridad: { type: string, enum: [baja, media, alta] }
resumen: { type: string }
tests:
- vars: { ticket: "Me cobraron dos veces por mi instancia este mes." }
assert: [{ type: icontains, value: facturación }]
- vars: { ticket: "Mi VM no arranca después de la última restauración de instantánea." }
assert: [{ type: icontains, value: técnica }]
EOF
npx -y promptfoo@latest eval -c promptfooconfig.yaml --no-cache
Sigue siendo 4/8 — los mismos modelos fallan. Así que response_format no cambió nada: en WEC hoy es efectivamente un no-op (el backend no está haciendo cumplir el modo JSON). El comportamiento de razonamiento pertenece a los modelos, no a la bandera.
Figura 5. Resultado idéntico sin modo JSON — la bandera no estaba haciendo nada.
Lección que merece su propio llamado: no atribuyas un resultado a un cambio hasta que hayas probado el cambio en aislamiento. Una ejecución adicional convirtió una conclusión incorrecta en la correcta.
Paso 6 — Una transformación más robusta: extraer el JSON
Los modelos de razonamiento sí emiten JSON — simplemente está enterrado en prosa. En lugar de eliminar cercas,
extrae el primer bloque { … } de cualquier parte de la salida:
cat > promptfooconfig.yaml <<'EOF'
description: "API de Inferencia WEC — transformación de extracción de JSON"
providers:
- id: openai:chat:zai-org/GLM-5.2
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
- id: openai:chat:gemma4
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
- id: openai:chat:Qwen2.5-3B-Instruct
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
- id: openai:chat:qwen3.5:9B
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
prompts:
- |
Clasifica este ticket de soporte. Devuelve SOLO JSON con claves:
"categoría" (una de: facturación, técnica, cuenta, otro),
"prioridad" (una de: baja, media, alta),
"resumen" (cadena, máximo 12 palabras).
Ticket: {{ticket}}
defaultTest:
options:
transform: |
const m = output.match(/\{[\s\S]*\}/);
return m ? m[0] : output;
assert:
- type: is-json
value:
type: object
required: [categoría, prioridad, resumen]
properties:
categoría: { type: string, enum: [facturación, técnica, cuenta, otro] }
prioridad: { type: string, enum: [baja, media, alta] }
resumen: { type: string }
tests:
- vars: { ticket: "Me cobraron dos veces por mi instancia este mes." }
assert: [{ type: icontains, value: facturación }]
- vars: { ticket: "Mi VM no arranca después de la última restauración de instantánea." }
assert: [{ type: icontains, value: técnica }]
EOF
npx -y promptfoo@latest eval -c promptfooconfig.yaml --no-cache
7/8 — un gran salto. gemma4 y Qwen2.5 se recuperan completamente; qwen3.5:9B recupera uno de dos. El
único fallo restante es revelador: esa respuesta emitió JSON y luego siguió razonando (con
más llaves), por lo que la codificación codiciosa { … } capturó en exceso y produjo JSON inválido.
Figura 6. Extraer el bloque JSON recupera los modelos habladores — pero es una heurística, no una garantía (nota el único fallo por captura excesiva).
Paso 7 — Verlo en el navegador
La tabla del terminal está bien para una lectura rápida, pero el informe web es donde una matriz como esta cobra vida:
npx -y promptfoo@latest view
El panel en la parte superior te da la imagen de fiabilidad del modelo de un vistazo — las barras de Tasa de Aprobación (%) son las que debes observar aquí: tres modelos altos, qwen3.5:9B sentado en 50%. Abajo, cada proveedor es una columna que puedes expandir para ver la respuesta completa (así es como obtuvimos el fallo por captura excesiva en Figura 10 — el terminal lo trunca).
Figura 7. El panel del informe — las barras de Tasa de Aprobación convierten la matriz de modelos en un ranking de un vistazo, y cualquier celda se expande a la salida completa.
Los otros gráficos del informe (el dispersor de aviso contra aviso) brillan cuando estás realizando pruebas A/B de avisos, no de modelos — un buen tema para una publicación posterior. Para una ejecución de un solo aviso y múltiples modelos como esta, las barras de Tasa de Aprobación son el gráfico que importa.
Paso 8 — Protegerlo en CI
Promptfoo sale con un código no cero cuando las pruebas fallan, así que una línea es una puerta (ponla en un script o Makefile, nunca en tu shell interactivo):
npx -y promptfoo@latest eval -c promptfooconfig.yaml \
&& echo "✅ aprobado" \
|| { echo "❌ evaluación fallida — bloqueando"; exit 1; }
Figura 8. Un fallo real bloquea: 7/8 aprobados, pero la captura excesiva de qwen3.5:9B (del Paso 6) activa la puerta — el código de salida no cero es la señal que CI usa para bloquear un PR.
En GitLab:
eval:
image: node:22
script:
- npx -y promptfoo@latest eval -c promptfooconfig.yaml
# WEC_API_KEY configurada como una variable CI/CD enmascarada — nunca comprometida.
Lo que aprendimos
- La elección del modelo domina.
GLM-5.2yQwen2.5-3Bproducen JSON válido según el esquema de manera fiable y son ~8× más baratos que los modelos de razonamiento. Para salida estructurada en WEC, comienza allí. - Los modelos de razonamiento (
qwen3.5:9B, ygemma4el día que probamos) narran su pensamiento y cuestan mucho más — malas opciones para JSON estricto. - Un
transformes tu red de seguridad — la eliminación de cercas recupera el caso común; la extracción de JSON recupera la mayoría de las salidas habladoras — pero la extracción es una heurística que puede capturar en exceso. response_formates un no-op en WEC hoy — verificado al aislarlo. No confíes en ello; valida en su lugar.- La evaluación es el punto. Cada una de estas sorpresas fue capturada automáticamente, antes de que llegara a producción.
Solución de problemas
"La función de transformación no devolvió un valor"
Un transform de varias líneas se compila como un cuerpo de función, por lo que necesita un return explícito. output.replace(...) por sí solo no devuelve nada.
options:
transform: |
return output.replace(/.../g, '').trim(); # nota el `return`
Figura 9. Sin return, Promptfoo da error en lugar de transformar.
Un modelo devuelve razonamiento alrededor del JSON
Si is-json falla con "Se esperaba que la salida fuera JSON válido" y la respuesta contiene una narración de "Proceso de Pensamiento…" / "Revisión Final…", estás usando un modelo de razonamiento. A veces emite solo razonamiento; a veces — como a continuación — emite JSON válido y luego sigue hablando, por lo que incluso la extracción del Paso 6 captura en exceso y el análisis falla. Opciones: cambia a GLM-5.2 / Qwen2.5-3B, o acepta que el transformador de extracción es un esfuerzo de buena fe, no una garantía.
Figura 10. El caso fallido expandido en el informe: qwen3.5:9B devolvió JSON válido y luego siguió razonando ("7. Revisión Final…"), por lo que la extracción capturó en exceso y la afirmación informa "Se esperaba que la salida fuera JSON válido."
"No se puede convertir el argumento a un ByteString"
Tu clave de API pegada contiene un carácter Unicode invisible. Elimínalo:
export WEC_API_KEY=$(printf '%s' "$WEC_API_KEY" | LC_ALL=C tr -cd '[:print:]')
Figura 11. Un carácter errante en la clave activa el encabezado HTTP antes de que se envíe cualquier solicitud.
¿Qué sigue?
- Conjuntos de regresión DeepEval — convierte estas verificaciones en una ejecución de regresión versionada y con puerta de CI.
- Trazado — ve por qué una respuesta salió mal, no solo que falló.
¿Tienes un caso de salida estructurada que aún se escapa? Esa es la próxima prueba que agregar.
