Saltar al contenido principal
intermediatePart 2

JSON confiable: valida el esquema de la salida estructurada de tu modelo

· 16 min de lectura
Rafael Fernandes
Ingeniero de PLN y Redactor Técnico en WiLine
Share:
Promptfoo++JSON
0/8
🎯 Skill path0/8 earned
AI evals & observability

"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 prueba pasa por el mismo pipeline. El detalle que más importa: el transform se ejecuta en la salida del modelo antes de que la afirmación la vea — eso es lo que nos permite recuperar 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 24
    export WEC_API_KEY='sk-tu-clave'
tip

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, otra),
"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, otra] }
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:

Salida
ticket [zai-org/GLM-5.2]
Me cobraron dos veces por mi instancia… [FALLIDO] ```json
{ "categoría": "facturación", "prioridad": "alta", … }
```
Mi VM no arranca después de la última… [APROBADO] { "categoría": "técnica", … }

✓ 1 aprobado (50%) ✗ 1 fallido (50%)

Paso 1 — un ticket falla porque el JSON está envuelto en cercas de markdown 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 porque la configuración en sí contiene tres comillas).

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, otra),
"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, otra] }
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.

Paso 2 — con la transformación de eliminación de cercas, ambos tickets pasan Figura 2. El transform elimina las cercas de código, por lo que el ticket que antes fallaba se analiza.

transform necesita un return explícito

Un 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, otra),
"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, otra] }
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.

Paso 3 — el modo JSON pasa en GLM-5.2 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. Este es el beneficio — 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, otra),
"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, otra] }
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.

Paso 4 — con modo JSON, dos modelos pasan y dos emiten razonamiento Figura 4. response_format configurado en los cuatro — pero los dos modelos orientados al razonamiento lo ignoran.

ModeloResultadoTokens (2 llamadas)
zai-org/GLM-5.2✅✅202
Qwen2.5-3B-Instruct✅✅243
gemma4❌❌ (razonamiento)251
qwen3.5:9B❌❌ (razonamiento)1,757
Habilidad desbloqueada 🏅

Ahora puedes medir qué modelos realmente honran el modo JSON — en lugar de confiar en la bandera.

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, otra),
"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, otra] }
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.

Paso 5 — mismo 4/8 sin response_format, demostrando que fue un no-op Figura 5. Resultado idéntico sin modo JSON — la bandera no estaba haciendo nada.

nota

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 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, otra),
"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, otra] }
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 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 coincidencia codiciosa { … } capturó en exceso y produjo JSON inválido.

Paso 6 — la extracción de JSON rescata la mayoría de los modelos (7/8) 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%. A continuación, cada proveedor es una columna que puedes expandir para ver la salida completa (así es como obtuvimos el fallo por captura excesiva en Figura 10 — el terminal lo trunca).

El informe de Promptfoo: barras de Tasa de Aprobación por modelo y salida por caso expandible 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.

nota

Los otros gráficos del informe (el dispersor de aviso frente a aviso) brillan cuando estás A/B-testando avisos, no 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 — Bloquearlo en CI

Promptfoo sale con un código no cero cuando las pruebas fallan, así que una línea es una puerta (ponlo 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; }

La puerta bloqueando en una evaluación fallida Figura 8. Un fallo real bloquea: 7/8 aprobados, pero la captura excesiva de qwen3.5:9B (del Paso 6) dispara 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.
Habilidad desbloqueada 🏅

JSON validado por esquema es ahora una puerta CI — un cambio en el modelo o aviso que rompe el análisis nunca se envía.

Lo que aprendimos

  • La elección del modelo domina. GLM-5.2 y Qwen2.5-3B producen JSON válido por 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, y gemma4 en el día que probamos) narran su pensamiento y cuestan mucho más — malas opciones para JSON estricto.
  • Un transform es 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_format es 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`

El error de retorno de transformación 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 la transformación de extracción es un esfuerzo de buena fe, no una garantía.

El caso fallido expandido en el informe — JSON seguido de razonamiento adicional Figura 10. El caso fallido expandido en el informe: qwen3.5:9B devolvió JSON válido y después 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:]')

El error de ByteString de un carácter clave invisible Figura 11. Un carácter extraño en la clave activa el encabezado HTTP antes de que se envíe cualquier solicitud.

Finished this tutorial?
Mark it complete to earn Trustworthy JSON on your skill path.

¿Qué sigue?

  • Conjuntos de regresión DeepEval — convierte estas verificaciones en una ejecución de regresión versionada y con puerta 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.

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.