Lección 6.2.2 · 75 min

Prompting estructurado: JSON que se puede validar

Un dato extraído por un LLM solo sirve si un programa puede leerlo. Esta lección define el contrato con Pydantic, se lo pide a un modelo de 0,5B parámetros en 20 comunicados de resultados y mide cuántas respuestas son JSON válido, qué arreglan los reintentos y la reparación, y qué garantiza la decodificación restringida que usan las APIs con salidas estructuradas.

Fig. — Respuestas de Qwen2.5-0.5B-Instruct válidas según el contrato de Pydantic (barra ancha) y correctas frente a la verdad (barra fina), de 20 comunicados de resultados, por método
Lecciones del nivel 6
  1. 6.1 · NLP financiero clásico y moderno

    1. 6.1.1 Tokenización, embeddings, diccionarios financieros y FinBERT
    2. 6.1.2 Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales
    3. 6.1.3 Event studies con señales de texto, alineación temporal y topic modeling
  2. 6.2 · LLMs para investigación y trading

    1. 6.2.1 Cómo funciona un LLM: del siguiente token a la API
    2. 6.2.2 Prompting estructurado: JSON que se puede validar
    3. 6.2.3 Guidance, KPIs y tono: qué extrae bien un LLM
    4. 6.2.4 Fine-tuning ligero para tareas financieras: LoRA y QLoRA
    5. 6.2.5 Sesgo de anticipación en LLMs: el modelo que ya conoce el futuro
  3. 6.3 · RAG financiero

    1. 6.3.1 Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant
    2. 6.3.2 Chunking de documentos financieros: tablas, notas al pie y XBRL
    3. 6.3.3 RAG sobre informes, folletos y KID, y cómo evaluarlo
  4. 6.4 · Agentes de IA para trading

    1. 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
    2. 6.4.2 Marcos de agentes y MCP
    3. 6.4.3 Arquitecturas multiagente tipo TradingAgents
    4. 6.4.4 Evidencia y límites de los agentes
    5. 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
    6. 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
  5. 6.5 · IA como copiloto del quant

    1. 6.5.1 Asistentes de código con un flujo de tests primero
    2. 6.5.2 Revisión automática de backtests y migraciones de código
    3. 6.5.3 Riesgos del código generado por IA
  6. 6.6 · Big data para trading

    1. 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
    2. 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
    3. 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
    4. 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
    5. 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
  7. 6.7 · Datos alternativos

    1. 6.7.1 Panorama y tamaño del mercado de datos alternativos
    2. 6.7.2 Tipos de datos alternativos: qué mide cada uno
    3. 6.7.3 Web scraping responsable
    4. 6.7.4 Proveedores y due diligence de datos alternativos
    5. 6.7.5 Del dato alternativo a la señal
    6. 6.7.6 Satélite, visión por computador y cadena de suministro

Del texto libre a un contrato

En la lección 6.2.1 el modelo respondía con frases. Para un programa, una frase es un problema: «la compañía espera entre 7,23 y 7,65 mil millones» hay que convertirla en dos números, una unidad y un trimestre antes de que sirva para algo, y cada respuesta llega redactada de otra manera. Lo que un pipeline de research necesita es un contrato: un formato fijo con campos, tipos y rangos, que el modelo rellena y que un programa comprueba antes de aceptar el dato.

El formato natural es JSON, y la herramienta natural para el contrato en Python es Pydantic (lección 0.4.2): define la estructura una vez y de ella salen el JSON Schema que se le enseña al modelo (o a la API) y la validación de lo que devuelve. El flujo completo de esta lección:

válidoinválidoinválidoDocumentoPrompt y esquemaLLMValidarDatoReintentarReparar
Fuente Mermaid
flowchart TB
  A[Documento] --> B[Prompt y esquema]
  B --> C[LLM]
  C --> D[Validar]
  D -->|válido| E[Dato]
  D -->|inválido| F[Reintentar]
  F --> C
  D -->|inválido| G[Reparar]
  G --> D

La tarea es extraer la previsión de ventas (el guidance) del comunicado de resultados de una empresa: trimestre, año y los dos extremos de la horquilla en millones de USD. Los documentos son los 8-K tipo SEC y los comunicados tipo CNMV de filings6_documentos.parquet, y la respuesta correcta de cada uno está en documentos6_extraccion_verdad.csv (README de los datos del nivel 6). Son datos sintéticos: las empresas no existen y las frases salen de plantillas, de modo que el resultado mide al modelo en un caso fácil, no en filings reales.

El contrato en Pydantic

IN [01]python
import json
from pydantic import (BaseModel, ConfigDict, Field,
                      ValidationError, model_validator)

class Prevision(BaseModel):
    """Previsión de ventas del trimestre siguiente."""
    model_config = ConfigDict(extra="forbid")
    trimestre: int | None = Field(ge=1, le=4)
    anio: int | None = Field(ge=2018, le=2026)
    bajo: float | None = Field(gt=0, description="millones de USD")
    alto: float | None = Field(gt=0, description="millones de USD")

    @model_validator(mode="after")
    def horquilla(self):
        if (self.bajo is None) != (self.alto is None):
            raise ValueError("bajo y alto: los dos o ninguno")
        if self.bajo is not None and self.bajo > self.alto:
            raise ValueError("bajo > alto")
        return self

pruebas = [
    '{"trimestre": 4, "anio": 2024, "bajo": 7230, "alto": 7650}',
    '{"trimestre": null, "anio": null, "bajo": null, "alto": null}',
    '{"trimestre": 4, "anio": 2024, "bajo": 7650, "alto": 7230}',
    '{"trimestre": "Q4", "anio": 2024, "bajo": 7230, "alto": 7650}',
    '{"trimestre": 4, "anio": 2024, "bajo": 7.230,0, "alto": 7650}',
    '{"trimestre": 4, "anio": 2024, "bajo": 1, "alto": 2, "x": 0}',
]
for t in pruebas:
    try:
        Prevision.model_validate_json(t)
        print("válido")
    except ValidationError as e:
        err = e.errors()[0]
        print(f"inválido · {err['type']} · {err['msg'][:44]}")
OUT [01]El modelo de datos de una previsión
válido
válido
inválido · value_error · Value error, bajo > alto
inválido · int_parsing · Input should be a valid integer, unable to p
inválido · json_invalid · Invalid JSON: key must be a string at line 1
inválido · extra_forbidden · Extra inputs are not permitted

El contrato rechaza seis maneras distintas de equivocarse con un mensaje que dice cuál. Tres detalles del diseño:

  • Los nulos son explícitos. Un documento sin previsión no es un error: todos los campos van a null. Sin esa salida, el modelo tiende a inventar una cifra para rellenar el hueco (lección 6.2.1).
  • Las unidades van en el contrato. «Millones de USD» está en la descripción del campo y en el prompt; sin ella, una previsión de «7,23 billion» puede llegar como 7,23, como 7.230 o como 7.230.000.000.
  • extra="forbid". Por defecto Pydantic ignora las claves que no conoce. Con un LLM eso es peligroso: un modelo pequeño copia a veces partes del esquema ("required": [...]) dentro de la respuesta, y sin forbid ese JSON pasaría como válido.

La regla «bajo ≤ alto» relaciona dos campos y no se puede escribir en JSON Schema: el esquema que se le enseña al modelo no la incluye y solo la comprueba el validador. El esquema que genera Pydantic, sin los títulos (que no aportan nada al modelo y cuestan tokens), y una firma de una línea derivada de él:

IN [02]python
def sin_titulos(s):
    if isinstance(s, dict):
        return {k: sin_titulos(v) for k, v in s.items()
                if k != "title"}
    if isinstance(s, list):
        return [sin_titulos(v) for v in s]
    return s

def firma(esquema):
    """Resumen de una línea del esquema, para el prompt."""
    partes = []
    for campo in esquema["required"]:
        prop = esquema["properties"][campo]
        t = next(a for a in prop["anyOf"] if a["type"] != "null")
        rango = (f" {t['minimum']}-{t['maximum']}" if "minimum" in t
                 else f" > {t['exclusiveMinimum']}")
        partes.append(f'"{campo}": {t["type"]}{rango} o null')
    return "{" + ", ".join(partes) + "}"

ESQUEMA = sin_titulos(Prevision.model_json_schema())
TXT_ESQUEMA = json.dumps(ESQUEMA, ensure_ascii=False,
                         separators=(",", ":"))
FIRMA = firma(ESQUEMA)
print(TXT_ESQUEMA)
print(FIRMA)
OUT [02]JSON Schema compacto para el prompt
{"additionalProperties":false,"description":"Previsión de ventas del trimestre siguiente.","properties":{"trimestre":{"anyOf":[{"maximum":4,"minimum":1,"type":"integer"},{"type":"null"}]},"anio":{"anyOf":[{"maximum":2026,"minimum":2018,"type":"integer"},{"type":"null"}]},"bajo":{"anyOf":[{"exclusiveMinimum":0,"type":"number"},{"type":"null"}],"description":"millones de USD"},"alto":{"anyOf":[{"exclusiveMinimum":0,"type":"number"},{"type":"null"}],"description":"millones de USD"}},"required":["trimestre","anio","bajo","alto"],"type":"object"}
{"trimestre": integer 1-4 o null, "anio": integer 2018-2026 o null, "bajo": number > 0 o null, "alto": number > 0 o null}

Un prompt estructurado

Un prompt para extraer datos tiene las mismas piezas siempre, en el mismo orden: el rol y la tarea (qué extraer), el formato (el esquema y nada más que JSON), las convenciones (unidades, separadores, cómo convertir «billion»), el caso sin dato y el texto, separado de las instrucciones. Las instrucciones van en el mensaje de sistema y el documento en el del usuario: así el documento no puede hacerse pasar por instrucción (la lección 6.4.5 trata la inyección de prompts).

IN [03]python
import re
import numpy as np
import pandas as pd
import torch
from bs4 import BeautifulSoup
from transformers import AutoModelForCausalLM, AutoTokenizer

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
NOMBRE = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(NOMBRE)
tok.padding_side = "left"
llm = AutoModelForCausalLM.from_pretrained(
    NOMBRE, dtype=torch.float32).eval()

SISTEMA = (
    "Extrae la previsión de ventas del trimestre siguiente. "
    "Responde solo con un objeto JSON así:\n" + FIRMA +
    "\nImportes en millones de USD, sin separador de miles "
    "(1 billion = 1000). Sin previsión: null.")

fil = pd.read_parquet("filings6_documentos.parquet")
items = fil["items"].fillna("").astype(str)
cand = fil[fil["tipo"].isin(["8-K", "OIR", "HR"])
           & ~items.str.contains("4.02")]
muestra = cand.sample(20, random_state=3)

def comunicado(t):
    """Texto del comunicado: lo que sigue al Exhibit 99.1 (SEC)
    o a las magnitudes (CNMV), sin tablas ni formato."""
    t = re.split(r"Exhibit 99\.1</h2>|## Principales magnitudes",
                 t)[-1]
    t = re.sub(r"<table>.*?</table>", "", t, flags=re.S)
    t = "\n".join(l for l in t.splitlines()
                  if not l.startswith("|"))
    t = BeautifulSoup(t, "html.parser").get_text("\n")
    return re.sub(r"\n\s*\n+", "\n", t.replace("*", "")).strip()

docs = [comunicado(t) for t in muestra["texto"]]
n_tok = [len(tok(d)["input_ids"]) for d in docs]
print(muestra["tipo"].value_counts().to_dict())
print("tokens: sistema", len(tok(SISTEMA)["input_ids"]),
      "· esquema completo", len(tok(TXT_ESQUEMA)["input_ids"]),
      "· firma", len(tok(FIRMA)["input_ids"]),
      "· documento (mediana)", int(np.median(n_tok)))
print(docs[0])
OUT [03]Cargar el modelo, el prompt y la muestra
{'8-K': 14, 'OIR': 4, 'HR': 2}
tokens: sistema 100 · esquema completo 144 · firma 51 · documento (mediana) 76
Revenue of $6.10 billion
Net income of $5,157.5 million, or $13.67 per share
Stores at quarter end: 324.
We currently anticipate the fourth quarter of 2024 revenue of between $7.23 billion and $7.65 billion.

El JSON Schema completo ocupa 144 tokens; la firma de una línea, 51, y el mensaje de sistema entero, 100. El esquema completo es lo que reciben las APIs con salidas estructuradas (más abajo); en un prompt escrito a mano, una firma derivada del mismo modelo de Pydantic dice lo mismo con un tercio de los tokens, y ese ahorro se multiplica por cada documento (con una API comercial, literalmente). Los comunicados se recortan a su parte de texto, sin tablas ni etiquetas, para que la lección corra en la CPU; aun así conservan la trampa principal, la cifra de ventas del trimestre que acaba de cerrar («Revenue of $6.10 billion»), que no es la previsión.

La verdad de cada documento:

IN [04]python
verdad = pd.read_csv("documentos6_extraccion_verdad.csv")
g = verdad[verdad["tipo"] == "guidance"].set_index("id_doc")

def esperado(id_doc):
    if id_doc not in g.index:
        return Prevision(trimestre=None, anio=None, bajo=None,
                         alto=None)
    v = g.loc[id_doc]
    return Prevision(trimestre=int(v["periodo"][-1]),
                     anio=int(v["periodo"][:4]),
                     bajo=v["bajo"], alto=v["alto"])

ESPERADO = [esperado(i) for i in muestra["id_doc"]]

def acierta(p, e):
    """Trimestre y año exactos; extremos con un error < 0,5 %."""
    if p is None:
        return False
    if e.bajo is None:
        return p.bajo is None and p.alto is None
    if p.bajo is None or (p.trimestre, p.anio) != (e.trimestre,
                                                  e.anio):
        return False
    return (abs(p.bajo / e.bajo - 1) < 0.005
            and abs(p.alto / e.alto - 1) < 0.005)

print(sum(e.bajo is not None for e in ESPERADO),
      "de 20 documentos traen previsión")
OUT [04]La respuesta correcta de cada documento
15 de 20 documentos traen previsión

Cuántas respuestas son JSON válido

La medida tiene dos niveles: validez (el texto es JSON y cumple el contrato) y exactitud (además, los valores coinciden con la verdad). Las 20 peticiones van en un lote con decodificación voraz y un máximo de 48 tokens por respuesta, más que suficiente para el JSON esperado.

IN [05]python
def generar(lista_mensajes, max_tokens=48, **kw):
    textos = [tok.apply_chat_template(m, tokenize=False,
                                      add_generation_prompt=True)
              for m in lista_mensajes]
    b = tok(textos, return_tensors="pt", padding=True)
    with torch.inference_mode():
        o = llm.generate(**b, do_sample=False,
                         max_new_tokens=max_tokens,
                         pad_token_id=tok.eos_token_id, **kw)
    n = b["input_ids"].shape[1]
    return [tok.decode(r[n:], skip_special_tokens=True)
            for r in o]

def validar(texto):
    try:
        return Prevision.model_validate_json(texto), None
    except ValidationError as e:
        return None, e.errors()[0]["type"]

def resumen(nombre, salidas):
    res = [validar(s) for s in salidas]
    ok = sum(p is not None for p, _ in res)
    bien = sum(acierta(p, e) for (p, _), e in zip(res, ESPERADO))
    print(f"{nombre}: {ok}/20 válidos, {bien}/20 correctos")
    return res

mensajes = [[{"role": "system", "content": SISTEMA},
             {"role": "user", "content": d}] for d in docs]
libre = generar(mensajes)
res_libre = resumen("libre", libre)
print(pd.Series([e or "válido" for _, e in res_libre])
      .value_counts().to_dict())
for k in [0, 1, 5, 6, 13]:
    corto = libre[k].replace("`", "'").replace("\n", " ")
    print(f"  {muestra['id_doc'].iloc[k]} "
          f"{res_libre[k][1] or 'válido':>16} · {corto[:58]}")
OUT [05]Extracción libre: validez y aciertos
libre: 2/20 válidos, 1/20 correctos
{'greater_than': 10, 'json_invalid': 5, 'value_error': 3, 'válido': 2}
  D003403      value_error · {   "trimestre": 4,   "anio": null,   "bajo": 5157.5,   "a
  D002322           válido · {   "trimestre": 3,   "anio": null,   "bajo": null,   "alt
  D002189     json_invalid · '''json {   "trimestre": 4,   "anio": 2022,   "bajo": 14.0
  D001708     greater_than · {   "trimestre": 3,   "anio": null,   "bajo": 0,   "alto":
  D003459     json_invalid · '''json {   "trimestre": 3,   "anio": null,   "bajo": 530,

Solo 2 de las 20 respuestas cumplen el contrato y solo 1 es correcta. Los errores no son aleatorios. La mitad (10) son greater_than: un importe que no es positivo, como el "bajo": 0 de D001708, aunque la firma diga «number > 0»; 5 no son JSON (dos de las que muestra la celda empiezan con un bloque de Markdown, que aparece con comillas simples); y 3 fallan en las reglas entre campos del validador (bajo ≤ alto; los dos importes o ninguno): D003403, por ejemplo, toma el beneficio (5.157,5) como extremo bajo. Varias dejan el año en null aunque el texto lo dice. Las válidas tampoco aciertan necesariamente: D002322 es válida con los importes en null y ese documento sí trae previsión.

Fig. — Extracción libre con Qwen2.5-0.5B-Instruct en 20 comunicados: primer error de validación de Pydantic de cada respuesta

La figura cuenta los tipos de error de la extracción libre. Lo que más falla no es la sintaxis, sino el contenido: un modelo de 0,5B no sigue con precisión una instrucción como «sin previsión: null» o «importes positivos».

Reintentar con el mensaje de error

La receta habitual con modelos grandes es devolverle al modelo su respuesta y el error del validador, y pedirle que lo corrija. Con los SDK de las APIs, el mismo bucle se escribe con unas pocas líneas y un número máximo de intentos. Aquí, un intento más para las respuestas inválidas:

IN [06]python
malas = [k for k, (p, _) in enumerate(res_libre) if p is None]
segundo = [mensajes[k] + [
    {"role": "assistant", "content": libre[k]},
    {"role": "user", "content":
     f"Error de validación: {res_libre[k][1]}. Corrige y "
     "responde solo con el JSON."}] for k in malas]
corregidas = generar(segundo)
reintento = list(libre)
for k, s in zip(malas, corregidas):
    reintento[k] = s
res_reint = resumen("con reintento", reintento)
for k, s in list(zip(malas, corregidas))[:4]:
    corto = s.replace("`", "'").replace("\n", " ")
    print(f"  {muestra['id_doc'].iloc[k]} · {corto[:62]}")
OUT [06]Reintento con el error de Pydantic
con reintento: 2/20 válidos, 1/20 correctos
  D003403 · null
  D003657 · null
  D000169 · null
  D003018 · null

El reintento no arregla nada: ninguna de las 18 respuestas inválidas pasa la validación en el segundo intento, y las cuatro que muestra la celda son un simple null (JSON válido, pero no un objeto Prevision). Un modelo de 0,5B no sabe usar el mensaje de error para corregir la respuesta y se limita a contestar algo corto. Con modelos grandes, el reintento con el error sí suele corregir una parte de los fallos; aquí el resultado se mide, no se supone, y cada reintento cuesta una llamada más con el historial entero.

Reparar sin el modelo

La otra vía es arreglar el texto con código determinista antes de validarlo: quedarse con el primer objeto JSON, quitar las claves que no están en el contrato, convertir a número un número escrito como texto y quitar los puntos de millar de una cifra como 13.561.9. Es barata y no gasta tokens, pero cada regla es una suposición sobre el error.

IN [07]python
CAMPOS = list(Prevision.model_fields)

def reparar(texto):
    m = re.search(r"\{.*?\}", texto, flags=re.S)
    if not m:
        return texto
    t = m.group(0)
    # 13.561.9 -> 13561.9: puntos de millar salvo el último
    t = re.sub(r"\d+(?:\.\d+){2,}",
               lambda x: x.group(0).replace(".", "", x.group(0)
                                            .count(".") - 1), t)
    try:
        d = json.loads(t)
    except json.JSONDecodeError:
        return t
    d = {k: v for k, v in d.items() if k in CAMPOS}
    for k, v in d.items():
        if isinstance(v, str):
            num = re.sub(r"[^\d.]", "", v.replace(",", "."))
            d[k] = float(num) if num else None
    return json.dumps(d)

reparado = [reparar(s) for s in reintento]
res_rep = resumen("con reparación", reparado)
OUT [07]Reparación determinista
con reparación: 5/20 válidos, 1/20 correctos

La reparación sube la validez de 2 a 5 respuestas sin gastar un token, y la exactitud no se mueve: sigue en 1. Lo que se ha reparado es la forma (bloques de Markdown alrededor, separadores, claves de más), no el contenido. Ese es el riesgo de reparar: una respuesta reparada pasa la validación y entra en el pipeline con la misma apariencia que una correcta. En producción, cada reparación se registra y su resultado se contrasta con la fuente.

Decodificación restringida: el formato por construcción

Las dos vías anteriores actúan después de generar. La decodificación restringida actúa durante la generación: en cada paso, antes de elegir el token, pone a −∞-\infty el logit de todo token que no pueda continuar un texto válido según el esquema. El modelo sigue eligiendo el token más probable, pero solo entre los permitidos. Willard y Louf (2023) mostraron cómo hacerlo de forma eficiente convirtiendo una expresión regular o una gramática en una máquina de estados finitos sobre el vocabulario (es la base de la librería Outlines); vLLM usa los motores xgrammar o guidance, y las APIs de OpenAI y Anthropic ofrecen salidas estructuradas con un esquema.

Para un objeto plano como Prevision, la máquina de estados es sencilla: literales fijos ({"trimestre": , , "anio": …) que el modelo tiene que escribir tal cual, y huecos para cada valor:

literal completodígito o puntovalor cerradollave finalLiteralValor
Fuente Mermaid
stateDiagram-v2
  [*] --> Literal
  Literal --> Valor: literal completo
  Valor --> Valor: dígito o punto
  Valor --> Literal: valor cerrado
  Literal --> [*]: llave final

En un hueco, los tokens permitidos son los dígitos que mantienen el número dentro de su tipo y su rango, el punto decimal si el campo es number, el comienzo de null y, cuando el número ya está completo, el comienzo del literal siguiente. Así se ve la máscara en el primer hueco del primer documento, después de que el modelo haya escrito {"trimestre": :

IN [08]python
V = len(tok)
PIEZA = tok.batch_decode([[i] for i in range(V)])
POR_TEXTO = {}
for i, s in enumerate(PIEZA):
    POR_TEXTO.setdefault(s, []).append(i)
DIGITOS = [i for d in "0123456789" for i in POR_TEXTO[d]]

def prefijos(resto):
    """Tokens que son un prefijo no vacío de `resto`."""
    return [i for n in range(1, len(resto) + 1)
            for i in POR_TEXTO.get(resto[:n], [])]

texto = tok.apply_chat_template(mensajes[0], tokenize=False,
                                add_generation_prompt=True)
x = tok(texto + '{"trimestre": ', return_tensors="pt")
with torch.inference_mode():
    z = llm(**x).logits[0, -1]
p = torch.softmax(z.double(), -1).numpy()
permitidos = ({i for i in DIGITOS if PIEZA[i] in "1234"}
              | set(prefijos("null")))
orden = np.argsort(-p)[:8]
pd.DataFrame({"token": [repr(PIEZA[i]) for i in orden],
              "prob_%": (100 * p[orden]).round(1),
              "permitido": [i in permitidos for i in orden]})
OUT [08]La máscara en un paso
  token  prob_%  permitido
0   '4'    84.4       True
1   '3'    10.3       True
2   '1'     2.9       True
3   '2'     1.0       True
4   '0'     0.4      False
5   '5'     0.4      False
6   '6'     0.2      False
7   '8'     0.1      False

En ese paso el modelo da el 84,4 % al token 4 (la previsión es del cuarto trimestre) y reparte el resto entre 3, 1 y 2. Los tokens 0, 5, 6 y 8 suman un 1,1 %, poco, pero en 20 documentos y cuatro campos un 1 % de probabilidad acaba saliendo; la máscara los elimina. La figura dibuja el paso.

Fig. — Decodificación restringida: probabilidad de los ocho tokens más probables tras «{"trimestre": » en el primer documento, y cuáles deja pasar la máscara (1 a 4 o null)

El decodificador completo lee la plantilla del propio JSON Schema (los campos en el orden de required, el tipo y el rango de cada uno) y se conecta a generate de transformers con prefix_allowed_tokens_fn, que en cada paso recibe lo generado hasta ahora y devuelve la lista de tokens permitidos:

IN [09]python
EOS = tok.eos_token_id
NUMERO = re.compile(r"null|\d+(\.\d*)?")

def plantilla(esquema):
    """Literales y huecos (tipo, mínimo, máximo) de un objeto
    plano, en el orden de `required`."""
    piezas = []
    for k, campo in enumerate(esquema["required"]):
        prop = esquema["properties"][campo]
        t = next(a for a in prop.get("anyOf", [prop])
                 if a["type"] != "null")
        piezas.append(("{" if k == 0 else ", ") + f'"{campo}": ')
        piezas.append((t["type"], t.get("minimum"),
                       t.get("maximum")))
    return piezas + ["}"]

def en_hueco(v, tipo, lo, hi, siguiente):
    """Tokens permitidos con el valor parcial `v` en un hueco."""
    ok = prefijos("null"[len(v):]) if "null".startswith(v) else []
    if v.startswith("n"):
        return ok
    if tipo == "integer" and lo is not None and hi is not None:
        validos = [str(i) for i in range(lo, hi + 1)]
        ok += [d for d in DIGITOS
               if any(s.startswith(v + PIEZA[d]) for s in validos)]
        return ok + (prefijos(siguiente) if v in validos else [])
    entero, _, dec = v.partition(".")
    if not v:                       # sin ceros a la izquierda
        ok += [d for d in DIGITOS if PIEZA[d] != "0"]
    elif len(entero) < 7 and "." not in v or (
            "." in v and len(dec) < 2):
        ok += DIGITOS
    if tipo == "number" and v and "." not in v:
        ok += POR_TEXTO["."]
    cerrado = v and not v.endswith(".")
    return ok + (prefijos(siguiente) if cerrado else [])

def permitidos_para(texto, piezas):
    pos = 0
    for k, pieza in enumerate(piezas):
        resto = texto[pos:]
        if isinstance(pieza, str):           # literal
            if resto.startswith(pieza):
                pos += len(pieza)
                continue
            return prefijos(pieza[len(resto):])
        m = NUMERO.match(resto)
        v = m.group(0) if m else resto
        if pos + len(v) < len(texto):        # hueco ya cerrado
            pos += len(v)
            continue
        return en_hueco(v, *pieza, piezas[k + 1])
    return [EOS]

def generar_restringido(lista_mensajes, esquema):
    piezas = plantilla(esquema)
    textos = [tok.apply_chat_template(m, tokenize=False,
                                      add_generation_prompt=True)
              for m in lista_mensajes]
    b = tok(textos, return_tensors="pt", padding=True)
    n = b["input_ids"].shape[1]

    def regla(_, ids):
        return permitidos_para(
            tok.decode(ids[n:], skip_special_tokens=True), piezas)

    with torch.inference_mode():
        o = llm.generate(**b, do_sample=False, max_new_tokens=60,
                         pad_token_id=EOS,
                         prefix_allowed_tokens_fn=regla)
    return [tok.decode(r[n:], skip_special_tokens=True)
            for r in o]

restringido = generar_restringido(mensajes, ESQUEMA)
res_restr = resumen("restringida", restringido)
for k in [0, 1, 5, 6, 13]:
    print(f"  {muestra['id_doc'].iloc[k]} · {restringido[k]}")
OUT [09]Un decodificador restringido para objetos planos
restringida: 13/20 válidos, 3/20 correctos
  D003403 · {"trimestre": 4, "anio": 2024, "bajo": 5157.5, "alto": 7650}
  D002322 · {"trimestre": 3, "anio": 2023, "bajo": 4.94, "alto": 5.15}
  D002189 · {"trimestre": 4, "anio": 2022, "bajo": 14.00, "alto": 14.73}
  D001708 · {"trimestre": 3, "anio": 2021, "bajo": 11.8, "alto": 43}
  D003459 · {"trimestre": 1, "anio": 2025, "bajo": 530, "alto": 555}

Con la máscara, 13 de las 20 respuestas son válidas y 3 correctas, frente a 2 y 1 de la extracción libre. Las 7 inválidas fallan todas por las reglas entre campos (bajo ≤ alto; los dos importes o ninguno), que no están en el JSON Schema y que la máscara, por tanto, no puede imponer (el ejercicio 2 lo comprueba). La exactitud mejora algo porque el modelo ya no puede escribir null en el año ni 0 en los importes y tiene que elegir una cifra del texto, pero las que elige muestran los mismos fallos de fondo: D003403 toma el beneficio (5.157,5) como extremo bajo; D002322 y D002189 copian las cifras en miles de millones sin convertirlas (4,94 y 5,15 donde la verdad es 4.936 y 5.147); D003459 acierta. El decodificador de la lección implementa solo una parte de JSON Schema (tipos, rangos de enteros, sin ceros a la izquierda); los motores de vLLM o de las APIs cubren mucho más, pero ninguno arregla que el modelo no sepa qué número es la previsión.

Salidas estructuradas en las APIs

Las APIs comerciales hacen esto en el servidor. El código real con los SDK oficiales, sin ejecutar (necesita clave y red); los dos aceptan directamente la clase de Pydantic:

IN [10]salida_estructurada_openai.pypython
from openai import OpenAI

cliente = OpenAI()
r = cliente.responses.parse(
    model="gpt-6-astra",
    instructions="Extrae la previsión de ventas del trimestre "
                 "siguiente en millones de USD.",
    input=texto_del_comunicado,
    text_format=Prevision,
)
prevision = r.output_parsed          # una instancia de Prevision
IN [11]salida_estructurada_anthropic.pypython
import anthropic

cliente = anthropic.Anthropic()
r = cliente.messages.parse(
    model="claude-opus-5-5",
    max_tokens=1024,
    system="Extrae la previsión de ventas del trimestre "
           "siguiente en millones de USD.",
    messages=[{"role": "user", "content": texto_del_comunicado}],
    output_format=Prevision,
)
prevision = r.parsed_output          # una instancia de Prevision

La documentación de OpenAI recomienda «always using Structured Outputs instead of JSON mode when possible»: el modo JSON solo garantiza que la respuesta sea JSON, no que cumpla el esquema. La de Anthropic explica que el SDK transforma el esquema del modelo de Pydantic, lo envía como output_config.format, valida la respuesta y la devuelve en parsed_output. Ollama acepta un JSON Schema en su parámetro format y vLLM en response_format (lección 6.2.1).

Para ver qué viaja de verdad, la lección monta otra vez un servidor local con la forma de las dos APIs (como en 6.2.1), pero que, cuando la petición trae un esquema, genera con el decodificador restringido de la celda anterior y ese esquema. Es lo que hace un servidor con salidas estructuradas, con un modelo de 0,5B en vez de uno comercial.

IN [12]python
import threading
from http.server import BaseHTTPRequestHandler
from http.server import ThreadingHTTPServer

class APIEstructurada(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def do_POST(self):
        n = int(self.headers["Content-Length"])
        req = json.loads(self.rfile.read(n))
        if self.path == "/v1/chat/completions":
            esq = req["response_format"]["json_schema"]["schema"]
            msgs = req["messages"]
        else:                                  # /v1/messages
            esq = req["output_config"]["format"]["schema"]
            msgs = ([{"role": "system", "content": req["system"]}]
                    + req["messages"])
        print("  [servidor]", self.path, "· trimestre:",
              json.dumps(esq["properties"]["trimestre"]["anyOf"][0],
                         ensure_ascii=False))
        txt = generar_restringido([msgs], esq)[0]
        if self.path == "/v1/chat/completions":
            r = {"id": "chatcmpl-local-2",
                 "object": "chat.completion",
                 "created": 1767225600, "model": req["model"],
                 "choices": [{"index": 0, "logprobs": None,
                              "finish_reason": "stop",
                              "message": {"role": "assistant",
                                          "content": txt,
                                          "refusal": None}}],
                 "usage": {"prompt_tokens": 0,
                           "completion_tokens": 0,
                           "total_tokens": 0}}
        else:
            r = {"id": "msg_local_2", "type": "message",
                 "role": "assistant", "model": req["model"],
                 "content": [{"type": "text", "text": txt}],
                 "stop_reason": "end_turn", "stop_sequence": None,
                 "usage": {"input_tokens": 0, "output_tokens": 0}}
        cuerpo = json.dumps(r).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(cuerpo)))
        self.end_headers()
        self.wfile.write(cuerpo)

srv = ThreadingHTTPServer(("127.0.0.1", 0), APIEstructurada)
threading.Thread(target=srv.serve_forever, daemon=True).start()
URL = f"http://127.0.0.1:{srv.server_port}"

El recuento de tokens de usage va a cero porque no interesa aquí. Primero, el SDK de OpenAI con chat.completions.parse, que acepta la clase de Pydantic en response_format:

IN [13]python
from openai import OpenAI

INSTR = ("Extrae la previsión de ventas del trimestre siguiente "
         "en millones de USD (1 billion = 1000).")
oa = OpenAI(base_url=URL + "/v1", api_key="local", max_retries=0)
for k in [0, 6]:
    try:
        r = oa.chat.completions.parse(
            model="qwen2.5-0.5b-instruct",
            response_format=Prevision,
            messages=[{"role": "system", "content": INSTR},
                      {"role": "user", "content": docs[k]}])
        print(" ", repr(r.choices[0].message.parsed))
    except ValidationError as e:
        print("  ValidationError:", e.errors()[0]["msg"])
    print("  esperado:", repr(ESPERADO[k]))
OUT [13]OpenAI: chat.completions.parse
  [servidor] /v1/chat/completions · trimestre: {"maximum": 4, "minimum": 1, "type": "integer"}
  Prevision(trimestre=1, anio=2024, bajo=7.23, alto=7.65)
  esperado: Prevision(trimestre=4, anio=2024, bajo=7228.0, alto=7648.0)
  [servidor] /v1/chat/completions · trimestre: {"maximum": 4, "minimum": 1, "type": "integer"}
  Prevision(trimestre=1, anio=2021, bajo=659.0, alto=692.0)
  esperado: Prevision(trimestre=4, anio=2021, bajo=659.0, alto=692.0)

El SDK de OpenAI ha enviado el esquema de Prevision como response_format de tipo json_schema con "strict": true, y los rangos han viajado: el servidor ve minimum 1 y maximum 4 en el trimestre y los aplica. La respuesta llega convertida en un objeto Prevision ya validado (message.parsed). Pero los valores están mal: el trimestre es 1 en los dos documentos (la previsión es del cuarto) y el primero da la horquilla en miles de millones (7,23 y 7,65). El servidor del simulador aplica el esquema como máscara pero no se lo enseña al modelo, y con la instrucción corta el modelo no sabe qué significa cada campo. Qué incluyen los servidores comerciales en el contexto del modelo no está documentado con ese detalle; lo que sí se ve aquí es que la máscara no sustituye a explicar la tarea.

IN [14]python
import anthropic

an = anthropic.Anthropic(base_url=URL, api_key="local",
                         max_retries=0)
for k in [0]:
    try:
        r = an.messages.parse(
            model="qwen2.5-0.5b-instruct", max_tokens=200,
            system=INSTR, output_format=Prevision,
            messages=[{"role": "user", "content": docs[k]}])
        print(" ", repr(r.parsed_output))
    except ValidationError as e:
        print("  ValidationError:", e.errors()[0]["msg"])
srv.shutdown()
OUT [14]Anthropic: messages.parse
  [servidor] /v1/messages · trimestre: {"type": "integer", "description": "{maximum: 4, minimum: 1}"}
  ValidationError: Input should be greater than or equal to 2018

El SDK de Anthropic 2.1.0 envía otro esquema para el mismo modelo de Pydantic: ha movido los rangos a la descripción del campo ("description": "{maximum: 4, minimum: 1}"), así que el servidor ya no puede forzarlos al decodificar y el modelo escribe un año fuera de 2018-2026. El SDK valida la respuesta con la clase de Pydantic al recibirla y lanza ValidationError. Es exactamente el papel de la validación del cliente: lo que no viaja en el esquema solo se comprueba al final, y por eso nunca se omite, aunque el servidor prometa un formato.

Validez no es exactitud

MétodoJSON válidoValores correctosCoste
Extracción libre (firma en el prompt)2/201/20Una llamada por documento
Más un reintento con el error2/201/20Una llamada más por cada respuesta inválida (18)
Más reparación determinista5/201/20Ninguno en tokens; riesgo de validar un error
Decodificación restringida13/203/20Una llamada; la máscara en cada paso

La figura de la cabecera dibuja esta tabla. Las técnicas de la lección actúan sobre la forma: la decodificación restringida lleva la validez de 2 a 13 respuestas (y a 20 si la regla entre campos no existiera), pero los valores correctos pasan solo de 1 a 3. Con un modelo de 0,5B, el cuello de botella es entender el documento, no escribir JSON. Con un modelo comercial y salidas estructuradas, la validez deja de ser el problema principal y la exactitud es lo que hay que medir; la lección 6.2.3 lo hace con previsiones, KPIs y tono, y compara el LLM con reglas, con FinBERT y con un diccionario.

Ejercicios

  1. Cambia el contrato para que el modelo devuelva también la moneda como texto con valores posibles "USD" o "EUR" (pista: Literal["USD", "EUR"] | None) y mira cómo aparece en el JSON Schema. ¿Podría el decodificador restringido de la lección manejar ese campo tal como está?
Solución
IN [15]python
from typing import Literal

class PrevisionMoneda(Prevision):
    moneda: Literal["USD", "EUR"] | None = None

esq = sin_titulos(PrevisionMoneda.model_json_schema())
print(json.dumps(esq["properties"]["moneda"]))
print(esq["required"])
OUT [15]Un campo enumerado en el esquema
{"anyOf": [{"enum": ["USD", "EUR"], "type": "string"}, {"type": "null"}], "default": null}
['trimestre', 'anio', 'bajo', 'alto']

El campo aparece como enum dentro de anyOf y, al tener valor por defecto, no entra en required. El decodificador de la lección solo conoce huecos numéricos y recorre required: ignoraría el campo. Para manejarlo haría falta un hueco de tipo enumerado, cuyos tokens permitidos sean los prefijos de "USD", "EUR" y null, la misma idea que el literal. Las salidas estructuradas estrictas de OpenAI exigen además que todos los campos estén en required (los opcionales se expresan con null), y su SDK reescribe el esquema para cumplirlo.

  1. El decodificador restringido garantiza que bajo es un número positivo, pero no que bajo ≤ alto ni que vengan los dos importes o ninguno. Cuenta en las 20 respuestas restringidas cuántas fallan solo por esas reglas.
Solución
IN [16]python
tipos = pd.Series([e or "válido" for _, e in res_restr])
tipos.value_counts()
OUT [16]Errores que el esquema no puede impedir
válido         13
value_error     7
Name: count, dtype: int64

Las reglas entre campos (model_validator) no existen en JSON Schema y ninguna API las impone al decodificar. Por eso la validación con Pydantic se hace siempre al recibir la respuesta, aunque el servidor prometa un formato.

  1. Escribe una función extraer(doc, llamar, intentos=2) que pida la extracción con la función llamar (que recibe los mensajes y devuelve el texto del modelo), valide, reintente con el error y, si sigue fallando, devuelva None con el motivo, sin reparar nada. Pruébala sin gastar un solo token: con una función falsa que primero responde con un JSON roto y después con uno válido. ¿Por qué conviene registrar el motivo además de devolver None?
Solución
IN [17]python
def extraer(doc, llamar, intentos=2):
    msgs = [{"role": "system", "content": SISTEMA},
            {"role": "user", "content": doc}]
    for _ in range(intentos):
        txt = llamar(msgs)
        p, err = validar(txt)
        if p is not None:
            return p, None
        msgs = msgs + [
            {"role": "assistant", "content": txt},
            {"role": "user", "content":
             f"Error de validación: {err}. Corrige y responde "
             "solo con el JSON."}]
    return None, err

respuestas = iter([
    '{"trimestre": 4, "anio": 2024, "bajo": 7.230,0}',
    '{"trimestre": 4, "anio": 2024, "bajo": 7230, "alto": 7650}'])
print(extraer(docs[0], lambda m: next(respuestas)))
print(extraer(docs[0], lambda m: "No hay previsión."))
OUT [17]Extracción con reintento, probada con un modelo falso
(Prevision(trimestre=4, anio=2024, bajo=7230.0, alto=7650.0), None)
(None, 'json_invalid')

Separar la llamada al modelo (llamar) del bucle permite probar la lógica de reintentos con respuestas fijas, en milisegundos y sin red; en producción llamar envuelve el SDK de la API o el modelo local. El motivo distingue un documento sin formato reconocible de un fallo sistemático (por ejemplo, todas las previsiones en miles de millones fallan por unidades). Contar motivos por tipo de documento, idioma o fecha es la manera de ver que un cambio en los documentos de origen o en el modelo ha roto la extracción.

Respuesta plegada en cada pregunta

Autoevaluación

  1. Con la decodificación restringida, las 20 respuestas del modelo de 0,5B cumplen el JSON Schema y 13 pasan además la validación de Pydantic. ¿Qué se puede concluir?

    • AQue las 20 extracciones son correctas
    • BQue el modelo ha entendido la tarea
    • CSolo que el formato es correcto: tipos, campos y rangos del esquema; los valores pueden seguir siendo erróneos, como muestra la tasa de aciertos frente a la verdad
    • DQue ya no hace falta validar con Pydantic
    Ver respuesta

    Respuesta C. Solo que el formato es correcto: tipos, campos y rangos del esquema; los valores pueden seguir siendo erróneos, como muestra la tasa de aciertos frente a la verdad

    La máscara impide escribir algo que no encaje en el esquema, pero no sabe qué cifra es la previsión: en la lección, 3 de 20 respuestas son correctas. Las 7 que no pasan Pydantic fallan por las reglas entre campos (bajo ≤ alto; los dos importes o ninguno), que no se expresan en JSON Schema y solo comprueba el validador.

  2. En el reintento, el modelo recibe su respuesta y el mensaje de error de Pydantic. ¿Qué hace el modelo de 0,5B en la mayoría de los casos?

    • ACorrige el campo que fallaba
    • BResponde con algo corto que no es una previsión (en la lección, null) y ninguna de las 18 respuestas pasa la validación
    • CSe niega a responder
    • DDevuelve el documento original
    Ver respuesta

    Respuesta B. Responde con algo corto que no es una previsión (en la lección, null) y ninguna de las 18 respuestas pasa la validación

    Un modelo tan pequeño no sabe usar el mensaje de error para corregir la respuesta. El reintento con el error funciona mucho mejor con modelos grandes; con este, hay que medirlo, no suponerlo, y cada reintento cuesta otra llamada con el historial.

  3. ¿Por qué el modelo de Pydantic de la lección usa extra="forbid"?

    • APara que el JSON ocupe menos tokens
    • BPorque OpenAI no admite campos opcionales
    • CPara que Pydantic convierta los textos a números
    • DPara que un JSON con claves que no están en el contrato (el modelo copia a veces required o title del esquema) se rechace en vez de aceptarse en silencio; y para que el esquema lleve additionalProperties: false, que piden las salidas estructuradas estrictas
    Ver respuesta

    Respuesta D. Para que un JSON con claves que no están en el contrato (el modelo copia a veces required o title del esquema) se rechace en vez de aceptarse en silencio; y para que el esquema lleve additionalProperties: false, que piden las salidas estructuradas estrictas

    Por defecto Pydantic ignora las claves desconocidas, y una respuesta que trae "required": [...] dentro pasaría como válida. Con forbid el error queda a la vista. El SDK de OpenAI envía el esquema con additionalProperties: false, requisito de su modo estricto.

  4. El SDK de Anthropic 2.1.0 envía el esquema de Prevision con los rangos (minimum, maximum) movidos a la descripción del campo. ¿Qué consecuencia tiene?

    • ANinguna: el servidor sigue aplicándolos
    • BEl servidor ya no puede forzar el rango al decodificar; el SDK valida la respuesta con el modelo de Pydantic al recibirla, y ahí es donde se detecta un trimestre 0 o 5
    • CLa respuesta deja de ser JSON
    • DEl modelo recibe un error 400
    Ver respuesta

    Respuesta B. El servidor ya no puede forzar el rango al decodificar; el SDK valida la respuesta con el modelo de Pydantic al recibirla, y ahí es donde se detecta un trimestre 0 o 5

    Lo que no viaja en el esquema no lo puede imponer la decodificación restringida: el servidor solo ve el texto de la descripción. La validación del cliente es la última barrera y no se puede omitir.

  5. Un analista propone pedir al LLM la previsión en JSON y, si falla, «arreglar» el texto con expresiones regulares que quitan puntos de millar. ¿Cuál es el riesgo principal?

    • AQue la reparación convierta en válido un JSON con un número mal interpretado (por ejemplo, una cifra en miles de millones leída como millones), que entonces pasa la validación y entra en el pipeline sin aviso
    • BQue tarde demasiado
    • CQue Pydantic no acepte números reparados
    • DNinguno: la reparación siempre mejora los datos
    Ver respuesta

    Respuesta A. Que la reparación convierta en válido un JSON con un número mal interpretado (por ejemplo, una cifra en miles de millones leída como millones), que entonces pasa la validación y entra en el pipeline sin aviso

    La reparación arregla la sintaxis, no el significado. En la lección sube la validez de 2 a 5 respuestas y los valores correctos siguen en 1. Toda reparación se registra y su resultado se contrasta con la fuente o con reglas de plausibilidad.

Ideas clave

  • El contrato va primero: un modelo de Pydantic con tipos, rangos, nulos, extra="forbid" y reglas entre campos. De él salen el JSON Schema para el prompt o la API y la validación de la respuesta.
  • Un prompt estructurado dice el rol, la tarea, el esquema, las unidades, qué hacer si no hay dato y dónde empieza el texto. El esquema cuesta tokens en cada llamada: 144 el JSON Schema completo y 51 una firma de una línea derivada de él.
  • Con un modelo de 0,5B, 2 de 20 respuestas son válidas y 1 correcta; los errores típicos son importes a cero, Markdown alrededor del JSON, cifras de distinta magnitud mezcladas y miles de millones sin convertir.
  • El reintento con el mensaje de error no ayuda a un modelo tan pequeño; la reparación determinista sube la validez de 2 a 5 sin mejorar la exactitud, y puede volver válido un dato erróneo.
  • La decodificación restringida enmascara los tokens que no encajan en el esquema: 13 de 20 respuestas válidas (las demás fallan una regla entre campos) y solo 3 correctas. Validez no es exactitud.
  • Las APIs con salidas estructuradas (OpenAI parse, Anthropic messages.parse, Ollama format, vLLM response_format) aplican esa idea en el servidor; lo que el SDK no envía en el esquema solo lo comprueba Pydantic al recibir la respuesta.

Recursos