Saltar al contenido principal

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

· 15 min de lectura
Rafael Fernandes
Ingeniero de PLN y Redactor Técnico en WiLine
Share:
Promptfoo++JSON

"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 24
    export WEC_API_KEY='sk-your-key'
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, 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:

Salida
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%)

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 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.

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 falló anteriormente 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, 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.

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. 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.

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

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.

Paso 5 — mismo 4/8 sin response_format, demostrando que no estaba haciendo nada 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, 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.

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%. 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).

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

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) 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.2 y Qwen2.5-3B producen 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, y gemma4 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 el transformador 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 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:]')

El error de ByteString de un carácter clave invisible 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.