Salida estructurada: generación restringida

Salida estructurada: generación restringida

35 min de lectura

Hasta aquí, el bloque ha pedido. La especificación le dijo al modelo qué forma tiene la respuesta, y la lección sobre la cadena de pensamiento, además, dónde ponerla. Con un modelo de verdad, pedir funciona casi siempre, y para el resto el harness mira cada respuesta y repite la llamada que no sirve. Lo que el bloque no ha tocado todavía es el sorteo. El modelo da los logits, y quien sortea con ellos el token siguiente es el código que lo rodea; cuando el modelo corre en una máquina tuya, como el mini-GPT en este navegador, ese código es tuyo.

Lo que un programa suele querer leer es JSON (JavaScript Object Notation), el formato en que se pasan datos casi todos los programas: un objeto entre llaves, con campos de nombre y valor y cada nombre entre comillas, como {"fecha": "2026-10-20"}. Un lector de JSON es más estricto que cualquier comprobación: con una comilla de menos, no lee nada. Y a tu mini-GPT ningún prompt le saca uno, porque en Marianela no hay ni una llave ni unas comillas rectas. Tras ¿Quién es la protagonista? Responde en JSON., le da a { una probabilidad de 1.2×10−71.2 \times 10^{-7}. ¿Qué pasa si el harness, antes de cada sorteo, deja sin probabilidad a todo token que rompería el JSON? La forma queda garantizada. Falta saber qué más cambia.

Sigue al mini-GPT desde el primer token: sus favoritas son un salto de línea (0.340.34) y palabras con su espacio delante, y { está entre las últimas de 512. Pero un JSON sólo puede empezar por una llave, así que el harness tira todas las demás entradas y le da a { el 100 %. Después sólo valen unas comillas, después cualquier entrada que siga escribiendo personaje, y así durante catorce posiciones en las que el texto ya está decidido, aunque no los tokens que lo escriben. En la decimoquinta hay, por primera vez, algo que elegir: la gramática de las celdas admite cinco nombres de la novela, y sus iniciales son cuatro entradas, C, N, P y T.

La máscara no mira adelante: elige la inicial del nombre por lo que el modelo escribiría tras unas comillas, y lo termina aunque el modelo no quiera. Elige el camino y toca un token para ver la posición que lo sorteó.

En la posición del nombre, el modelo le da a P 2.5×10−52.5 \times 10^{-5} y a N casi cuarenta veces menos, y la máscara reparte el 100 % en esa misma proporción: un 61 % para P, un 1.6 % para N. Mira qué viene después en cada camino. Tras la N, el mini-GPT escribe ela con un 58 % sin máscara: ha leído Nela cientos de veces. Tras la P apuesta por or, la de Por, y a las dos entradas que siguen escribiendo Pablo les deja un 4.6 % entre las dos. La máscara se lo da todo a esas dos. Nunca preguntó si el modelo quería escribir Pablo: preguntó qué inicial le gustaba más, y la P es la de Pero, Por y Pues.

La máscara es el corte sobre lo que la gramática permite

Llamemos gramática a un conjunto de textos, G\mathcal{G}: los que el programa sabe leer. Un esquema de JSON es una manera habitual de escribirla (qué campos lleva el objeto y qué valores admite cada uno), y la de las celdas es pequeña: cinco textos, {"personaje": "Nela"} y el mismo con Pablo, Teodoro, Celipín o Choto. Una respuesta yy es válida cuando su texto está en G\mathcal{G}, y como varias secuencias de tokens escriben el mismo texto, todas cuentan.

Sea xx el prompt e y<jy_{<j} lo que la respuesta lleva escrito. Las entradas permitidas en la posición que sortea yjy_j son

Aj={ v∈V:los bytes de y<j seguidos de los de v empiezan un texto de G }.A_j = \{\, v \in V : \text{los bytes de } y_{<j} \text{ seguidos de los de } v \text{ empiezan un texto de } \mathcal{G} \,\}.

Bytes y no caracteres, porque un token puede ser medio carácter: el mini-GPT tiene entradas que son sólo el primer byte de una í, y tras Celip una de ellas está permitida.

La máscara pone zv=−∞z_v = -\infty en toda entrada fuera de AjA_j, donde zvz_v es la entrada vv de los logits z(x,y<j)\mathbf{z}(x, y_{<j}) de esa posición, y aplica la softmax. Como e−∞=0e^{-\infty} = 0, a cada v∈Ajv \in A_j le queda

ezv∑u∈Ajezu=ezv/∑u∈Vezu∑u∈Ajezu/∑u∈Vezu=pθ(v∣x,y<j)pθ(Aj∣x,y<j),\frac{e^{z_v}}{\sum_{u \in A_j} e^{z_u}} = \frac{e^{z_v} \big/ \sum_{u \in V} e^{z_u}}{\sum_{u \in A_j} e^{z_u} \big/ \sum_{u \in V} e^{z_u}} = \frac{p_\theta(v \mid x, y_{<j})}{p_\theta(A_j \mid x, y_{<j})},

donde pθ(Aj∣x,y<j)p_\theta(A_j \mid x, y_{<j}) es la masa de las permitidas, y fuera de AjA_j, cero. Dividir arriba y abajo por la misma suma no cambia el cociente, y convierte cada término en una probabilidad del modelo. Es el corte de la lección sobre el muestreo con AjA_j en el lugar del núcleo, y es también la probabilidad de vv sabiendo que el token sale de AjA_j. Escribamos q~(v∣x,y<j)\tilde{q}(v \mid x, y_{<j}) para ese cociente, como allí, y q~(y∣x)\tilde{q}(y \mid x) para su producto a lo largo de la respuesta. (Con τ=1\tau = 1 y sin otro corte, para que lo recortado sea pθp_\theta; con temperatura, vale igual para q\mathbf{q}.) No es la máscara causal de la atención ni la de la pérdida del ajuste: ésta actúa a la salida de la red, sobre los logits.

Generar con la máscara en cada posición es la generación restringida (constrained decoding; no decimos decodificación porque decodificar es pasar de ids a texto).

Toda respuesta generada con la máscara es válida

La demostración es una inducción sobre jj. Al principio no hay nada escrito, y el texto vacío empieza cualquier texto de G\mathcal{G}. Supongamos que lo escrito hasta y<jy_{<j} empieza algún texto g∈Gg \in \mathcal{G}. Si ya es gg entero, el harness para. Si no, el byte siguiente de gg es una entrada de VV, porque los 256 bytes lo son desde el BPE a nivel de byte; así que AjA_j no está vacío y la softmax tiene algo que repartir. Y lo que se sortee está en AjA_j, así que lo escrito con yjy_j vuelve a empezar un texto de G\mathcal{G}. Como cada token añade al menos un byte y los textos de G\mathcal{G} tienen una longitud máxima, la generación acaba, y acaba en un texto de G\mathcal{G}.

La prueba se apoya en dos condiciones. Ningún texto de G\mathcal{G} puede ser el principio de otro, para que parar al completar uno no corte otro más largo: un objeto JSON se cierra con su llave, y nada lo continúa. (Un modelo con token de fin lo resuelve de otra manera: la gramática lo permite justo cuando el texto está completo.) Y los textos necesitan un largo máximo: el mini-GPT le da a cerrar unas comillas del orden de 10−610^{-6}, y en un campo de texto libre tardaría un millón de tokens en hacerlo.

La máscara no sortea lo que el modelo diría

Que la respuesta sea válida no dice cuál de las válidas sale, y ahí la máscara y el modelo dejan de estar de acuerdo. Lo que el modelo diría si sólo pudiera dar respuestas válidas es su propia distribución sobre respuestas enteras, recortada a G\mathcal{G} y renormalizada:

pθ(y∣x,G)=pθ(y∣x)pθ(G∣x)para y∈G,p_\theta(y \mid x, \mathcal{G}) = \frac{p_\theta(y \mid x)}{p_\theta(\mathcal{G} \mid x)} \quad \text{para } y \in \mathcal{G},

la condicionada, donde pθ(G∣x)p_\theta(\mathcal{G} \mid x) es la masa de las respuestas válidas, la probabilidad de que el modelo escriba un texto de G\mathcal{G} sin máscara, con el harness parando en cuanto lo complete o se salga. Para un solo token, la definición de probabilidad condicionada da pθ(yj,G∣x,y<j)/pθ(G∣x,y<j)p_\theta(y_j, \mathcal{G} \mid x, y_{<j}) / p_\theta(\mathcal{G} \mid x, y_{<j}), y el numerador es la probabilidad de escribir yjy_j por la de acabar en G\mathcal{G} después:

pθ(yj∣x,y<j,G)=pθ(yj∣x,y<j) pθ(G∣x,y≤j)pθ(G∣x,y<j),p_\theta(y_j \mid x, y_{<j}, \mathcal{G}) = p_\theta(y_j \mid x, y_{<j}) \, \frac{p_\theta(\mathcal{G} \mid x, y_{\le j})}{p_\theta(\mathcal{G} \mid x, y_{<j})},

donde pθ(G∣x,y≤j)p_\theta(\mathcal{G} \mid x, y_{\le j}) es la probabilidad de acabar en un texto de G\mathcal{G} con y≤jy_{\le j} ya escrito. Vale cero fuera de AjA_j, y dentro mide lo que le cuesta al modelo escribir lo que falta. (El producto de estos factores sobre jj se telescopa hasta pθ(G∣x,y)/pθ(G∣x)p_\theta(\mathcal{G} \mid x, y) / p_\theta(\mathcal{G} \mid x), y con yy válida y completa el numerador vale 1: la condicionada de arriba.)

La máscara usa el mismo primer factor y, en el lugar del cociente, divide por pθ(Aj∣x,y<j)p_\theta(A_j \mid x, y_{<j}), el mismo número para todas las permitidas. Trata a cada entrada de AjA_j como si lo que falta por escribir estuviera asegurado. A lo largo de una respuesta entera,

q~(y∣x)=∏jpθ(yj∣x,y<j)pθ(Aj∣x,y<j)=pθ(y∣x)∏jpθ(Aj∣x,y<j).\tilde{q}(y \mid x) = \prod_{j} \frac{p_\theta(y_j \mid x, y_{<j})}{p_\theta(A_j \mid x, y_{<j})} = \frac{p_\theta(y \mid x)}{\prod_{j} p_\theta(A_j \mid x, y_{<j})}.

Las dos distribuciones tienen el mismo numerador. La condicionada divide toda respuesta válida por el mismo número; la máscara divide cada una por el producto de las masas que conservó en su camino, y ese producto es pequeño justo en los caminos por los que el modelo quería salirse de la gramática. Ésos son los que la máscara infla. Pablo es uno: tras su P, la máscara conservó un 4.6 % de la masa, y tras la N de Nela, un 59 %.

El rechazo sortea bien, a un precio imposible

La condicionada se puede sortear sin calcular ningún cociente, con el muestreo por rechazo (rejection sampling): sortear respuestas enteras sin máscara y tirar cada una que no sea válida, hasta que salga una. Cada intento es independiente y sale válido con probabilidad pθ(G∣x)p_\theta(\mathcal{G} \mid x), así que yy sale en el intento ii, tras i−1i - 1 tirados, con probabilidad (1−pθ(G∣x))i−1pθ(y∣x)\bigl(1 - p_\theta(\mathcal{G} \mid x)\bigr)^{i-1} p_\theta(y \mid x). La suma sobre ii es una serie geométrica,

∑i=1∞(1−pθ(G∣x))i−1pθ(y∣x)=pθ(y∣x)pθ(G∣x),\sum_{i=1}^{\infty} \bigl(1 - p_\theta(\mathcal{G} \mid x)\bigr)^{i-1} p_\theta(y \mid x) = \frac{p_\theta(y \mid x)}{p_\theta(\mathcal{G} \mid x)},

y da la condicionada exacta. El precio es el número de intentos, cuya esperanza es

E[intentos]=∑i=1∞i (1−pθ(G∣x))i−1pθ(G∣x)=1pθ(G∣x),\mathbb{E}[\text{intentos}] = \sum_{i=1}^{\infty} i \, \bigl(1 - p_\theta(\mathcal{G} \mid x)\bigr)^{i-1} p_\theta(\mathcal{G} \mid x) = \frac{1}{p_\theta(\mathcal{G} \mid x)},

porque la serie con el factor ii es la derivada de la geométrica respecto de su razón, y vale el cuadrado de lo que valía aquélla, 1/pθ(G∣x)21/p_\theta(\mathcal{G} \mid x)^{2}. Un intento puede en cuanto su texto deja de empezar uno de G\mathcal{G}, con la misma comprobación de prefijo que usa la máscara; eso ahorra pasadas, no intentos.

La máscara, sobre el mini-GPT

Las dos celdas corren sobre tu mini-GPT del bloque 1 y no sobre el modelo guionizado: la máscara actúa sobre los logits, y el modelo guionizado devuelve texto sin pasar por ninguno. La primera escribe la gramática, permitidas y una generación que sortea de la softmax enmascarada, y le da el prompt al mini-GPT sin máscara y con ella. Mira tres cosas: lo que escribe sin ella, que json.loads lee cada respuesta con ella, y qué tokens eligió en cada una.

import json
import numpy as np
from pyodide.http import open_url
exec(open_url("/courses/llm-agents/modelo.py").read()) # red, F, llenar, avanzar, softmax

VOC = vocabulario(F) # id -> sus bytes
NOMBRES = ["Nela", "Pablo", "Teodoro", "Celipín", "Choto"]
G = {('{"personaje": "%s"}' % n).encode() for n in NOMBRES} # la gramática: cinco textos
PRINCIPIOS = {g[:i] for g in G for i in range(len(g) + 1)} # todo principio de uno de ellos

def permitidas(texto):
"""A_j: las entradas cuyos bytes, tras los de texto, empiezan algún texto de G."""
return [v for v in range(len(VOC)) if texto + VOC[v] in PRINCIPIOS]

def con_mascara(prompt, rng):
"""Una respuesta sorteada con la máscara: sus bytes y sus tokens."""
z, cache = llenar(red, codificar(prompt, F))
texto, tokens = b"", []
while texto not in G: # ningún texto de G empieza otro
p = softmax(z)
A = permitidas(texto)
v = int(rng.choice(A, p=p[A] / p[A].sum())) # la softmax con -inf fuera de A_j
texto += VOC[v]
tokens.append(VOC[v].decode("utf-8", "replace"))
z = avanzar(red, v, cache)
return texto, tokens

prompt = "¿Quién es la protagonista? Responde en JSON."
print("sin máscara:", repr(modelo(prompt, max_tokens=16, semilla=0)))
rng = np.random.default_rng(0)
for _ in range(5):
texto, tokens = con_mascara(prompt, rng)
personaje = json.loads(texto)["personaje"] # si no fuera JSON, fallaría aquí
print("con máscara: %-8s %2d tokens %s" % (personaje, len(tokens), "·".join(tokens)))
numpy

La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador y se reutiliza en todas las lecciones.

Sin máscara, el mini-GPT sigue escribiendo a la manera de Galdós. Con ella, cinco JSON que json.loads lee, de un modelo que nunca había visto una llave. El texto es uno de cinco; los tokens, no: tras pers, las respuestas escriben o y n sueltas donde el tokenizador habría puesto on, porque la máscara sortea entre todas las permitidas y el modelo, tras pers, apuesta por la o. Y entre los cinco nombres no sale Nela.

¿Es eso lo que diría el mini-GPT si sólo pudiera dar respuestas válidas? La segunda celda no sortea: recorre todos los caminos de tokens que acaban en un texto de G\mathcal{G}, una pasada por posición, y suma lo que vale cada uno sin máscara y con ella.

# Necesita la celda anterior.
def recorrer(texto, z, cache, p_sin, p_con, hojas):
"""Cada camino que acaba en un texto de G, con lo que vale sin máscara y con ella."""
if texto in G:
hojas.append((json.loads(texto)["personaje"], p_sin, p_con))
return
p = softmax(z)
A = permitidas(texto)
masa = p[A].sum() # p(A_j | x, y_<j)
for v in A:
rama = list(cache) # cada rama, con su propia caché
recorrer(texto + VOC[v], avanzar(red, v, rama), rama,
p_sin * p[v], p_con * p[v] / masa, hojas)

hojas = []
recorrer(b"", *llenar(red, codificar(prompt, F)), 1.0, 1.0, hojas)
p_G = sum(p_sin for _, p_sin, _ in hojas) # p(G | x)
print("%d caminos acaban en un texto de G" % len(hojas))
print("p(G | x) = %.1e: el rechazo necesita, de media, %.1e intentos" % (p_G, 1 / p_G))
print("\n%-9s %12s %9s" % ("personaje", "condicionada", "máscara"))
for n in NOMBRES:
condicionada = sum(p for m, p, _ in hojas if m == n) / p_G
mascara = sum(q for m, _, q in hojas if m == n)
print("%-9s %12.3f %9.3f" % (n, condicionada, mascara))
numpy

La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador y se reutiliza en todas las lecciones.

Son 132 caminos. Sin máscara, el mini-GPT escribe un JSON válido con probabilidad 8.1×10−638.1 \times 10^{-63}, así que el rechazo necesitaría, de media, 1.2×10621.2 \times 10^{62} intentos: a un nanosegundo cada uno, unos 4×10454 \times 10^{45} años. La máscara saca uno con una pasada por token.

La tabla es la otra mitad de la lección. Entre las respuestas válidas, el mini-GPT prefiere Nela casi siempre, un 99.4 %, y no porque entienda la pregunta: una vez escrita la N, sabe terminar ese nombre mucho mejor que cualquier otro. La máscara le da a Nela un 1.6 % y a Pablo un 62 %, y deja a Choto por delante de ella. Las dos columnas salen de las mismas probabilidades. Sólo cambia por qué se divide.

Para el harness quedan tres consecuencias. La máscara garantiza lo que la gramática describe, y nada más. Deforma poco a un modelo que ya escribe la forma, porque si cada masa conservada está cerca de 1, su producto también, y q~\tilde{q} se queda cerca de pθp_\theta; por eso el prompt sigue pidiendo el JSON que la máscara impone, y pedir y forzar se suman. Y escribe los campos en el orden del esquema, mientras que una cadena de pensamiento sólo sirve antes de la respuesta: un esquema con un campo para los pasos delante del de la fecha deja pensar al modelo sin salirse del JSON.

Comprueba tu intuición

Cuatro preguntas: la condicionada en pequeño, la comprobación, cuándo coinciden las dos distribuciones y las celdas. Después, la máscara del programa de las fechas.

Una gramática admite dos textos, Pablo y Nela, y al principio sólo permite dos entradas: P, a la que el modelo da 0.30.3, y Nela, a la que da 0.10.1. Tras P sólo permite ablo, y el modelo le da 0.10.1. Con la máscara, la respuesta es Pablo con probabilidad 0.750.75. ¿Con qué probabilidad lo es en la distribución condicionada a la gramática? Tres decimales.

Se acepta un margen de ±0.002.

El programa de las fechas genera ahora con la máscara de la gramática del desafío: {"fecha": "####-##-##"}, con una cifra en cada #, o {"fecha": null}. ¿Qué le queda por mirar a la comprobación de la lección sobre la especificación?

Marca los casos en que la máscara sortea exactamente, o casi, de la distribución condicionada a la gramática.

Marca todas las opciones correctas. Se corrige todo o nada: no hay puntuación parcial.

Las celdas corren sobre el mini-GPT. Marca lo que demuestran.

Marca todas las opciones correctas. Se corrige todo o nada: no hay puntuación parcial.

El programa de las fechas pide ahora un JSON: {"fecha": "2026-10-20"} o, si el mensaje no fija ningún día, {"fecha": null}. Su gramática son las dos plantillas de PLANTILLAS, donde cada # es una cifra del 0 al 9. Implementa viable(texto), que dice si texto es el principio de alguno de los textos de la gramática, y permitidas(texto, vocab), que devuelve, de menor a mayor, los ids de vocab (un diccionario de id a texto) que la máscara deja pasar después de texto. Aquí ningún carácter ocupa más de un byte, así que puedes comparar caracteres.

La primera comprobación descarga el intérprete de Python (~15 MB); después queda en la caché del navegador. Este desafío se resuelve mejor con un teclado físico: en el móvil puedes leerlo y volver luego.


La máscara le quita al modelo la libertad de equivocarse de forma, y ninguna otra. Lo que escribe dentro de la forma sale de sus pesos: el mini-GPT pone Nela porque la novela la repite, y si le preguntas por el personaje de otro libro te dará, igual de bien formado, uno de los cinco. Ninguna gramática le da un dato que no tiene.

La lección siguiente, sobre la recuperación, se ocupa de esa otra mitad: buscar en un corpus los pasajes que responden a la pregunta y ponerlos en el prompt, donde el modelo los puede leer. Para encontrarlos hace falta medir qué texto se parece a cuál, y para eso vuelven los embeddings.

¿Te ha sido útil?
Para profundizar4 fuentes · 4 papers

De dónde sale lo de esta lección, y dónde seguir si quieres más. Nada de aquí hace falta para continuar el curso.

  • Grammar-Aligned Decoding
    paperPark, Wang, Berg-Kirkpatrick, Polikarpova y D'Antoni, 2024NeurIPS 2024 · arXiv:2405.21047EN

    La deformación de esta lección, con nombre: la máscara da respuestas válidas con probabilidades que no son las del modelo. Proponen estimar, con los sorteos anteriores, el factor que la máscara ignora.

  • Efficient Guided Generation for Large Language Models
    paperWillard y Louf, 2023arXiv:2307.09702EN

    Cómo calcular $A_j$ sin recorrer el vocabulario en cada posición: la gramática se convierte en un autómata y, antes de generar, se apunta qué entradas acepta cada estado.

  • Guiding LLMs The Right Way: Fast, Non-Invasive Constrained Generation
    paperBeurer-Kellner, Fischer y Vechev, 2024arXiv:2403.06988EN

    La máscara obliga a cortar el texto en tokens que el modelo no usaría, como el <W>o</W> y la <W>n</W> sueltas de la celda, y eso le cuesta acierto. Proponen restringir sin desalinear la gramática de los tokens.

  • Let Me Speak Freely? A Study on the Impact of Format Restrictions on Performance of Large Language Models
    paperTam, Wu, Tsai, Lin, Lee y Chen, 2024arXiv:2408.02442EN

    Pedirle a un modelo la respuesta en JSON o XML le baja el acierto en tareas de razonamiento, y más cuanto más estricto es el formato. Una razón más para dejarle un campo donde pensar antes de la respuesta.