TL;DR
- Un verificador de respuestas basado en LLM tiene un límite de contexto. Si el tokenizador corta por la derecha lo que se pasa del límite, y la plantilla pone la respuesta al final, la respuesta desaparece. Dos respuestas distintas producen exactamente la misma entrada, el mismo vector y el mismo puntaje. No salta ningún error.
- Nos pasó a nosotros. En el código público de ZTC-Judge-27B (un verificador de VIDRAFT que lee una pregunta y una respuesta y devuelve un puntaje sin generar ningún token), una prueba interna con 57 pares de contexto largo (mediana de 3.867 tokens) dio 57 empates exactos entre la opción correcta y la incorrecta. Cero excepciones.
- El arreglo publicado el 2 de noviembre de 2026 recorta solo la pregunta (conserva su cabecera, descarta su cola) y deja intactos la plantilla, la respuesta y la pregunta final. En la misma medición interna, la respuesta correcta puntúa más alto en 43 de 57 pares, con 0 empates, y en entradas cortas el puntaje no cambia (diferencia 0,0 en 20 casos).
- Nuestro primer arreglo estaba mal (cortar por la izquierda: 24 de 57). Lo contamos abajo. Todas las cifras son autorreportadas, de un conjunto pequeño, y señalamos sus límites.
- Al final hay tres pruebas cortas que puedes pegar en tu propio verificador, evaluador o clasificador para saber si te pasa lo mismo.
¿Qué es ZTC-Judge y por qué importa dónde cae el último token?
ZTC (Zero-Token Confidence) es un método de VIDRAFT para estimar si una respuesta es confiable sin generar ni un token. En el caso de ZTC-Judge-27B, el flujo es este:
[pregunta + respuesta] -> un solo forward pass por el modelo de 27B -> estado oculto de la última capa, última posición (5.120 dim.) -> un producto punto con la sonda -> puntaje Como no se decodifica nada, la latencia es un forward pass y el procesamiento por lotes escala bien. La contrapartida es que todo el juicio depende de un único vector: el de la última posición no vacía. La plantilla, que está en el ztc_config.json público, es esta (en coreano):
다음은 어떤 문제와 그에 대한 답변이다. 이 답변이 옳은지 판단하라. [문제] %s [답변] %s 이 답변은 옳은가? Dos detalles de diseño que parecen inocentes: la respuesta va después de la pregunta, y la frase final ("¿Es correcta esta respuesta?") va después de la respuesta. El vector que se lee es el de esa frase final, que es donde el modelo ha visto todo. El límite de entrada del modelo en la configuración pública es max_length = 2048.
¿Qué pasa cuando la entrada supera el límite y el tokenizador corta por la derecha?
El cargador público hacía lo habitual en cualquier código de Hugging Face:
b = tok(texts, return_tensors="pt", padding=True, truncation=True, max_length=cfg["max_length"]) Con truncation=True y el truncation_side por defecto ("right"), todo lo que pasa de 2.048 tokens se descarta desde el final. Con esta plantilla, el final es justo donde están la respuesta y la pregunta final. Lo que queda es la plantilla, la pregunta (cortada a mitad de frase) y nada más.
El resultado es peor que un error, porque es un resultado plausible. El modelo lee el último token que sobrevivió (una palabra cualquiera del medio de la pregunta), produce un vector razonable y la sonda devuelve un número. Ese número no depende de la respuesta. Si preguntas con C. Allomone o con B. Pheromone, los input_ids son idénticos.
Lo comprobamos con el tokenizador real de ZTC-Judge-27B, usando una pregunta de relleno de longitud creciente y dos respuestas distintas:
| Tokens de la pregunta (aprox.) | Tokens totales | Entran en la ventana | Descartados | ¿Sobrevive la respuesta? | ¿Colisionan las dos respuestas? |
|---|---|---|---|---|---|
| 1.457 | 1.501 | 1.501 | 0 | Sí | No |
| 2.419 | 2.463 | 2.048 | 415 | No | Sí |
| 3.745 | 3.789 | 2.048 | 1.741 | No | Sí |
| 5.799 | 5.843 | 2.048 | 3.795 | No | Sí |
En cuanto la entrada pasa de 2.048 tokens, la respuesta no entra. No hay umbral gradual: hasta 2.048 funciona y a partir de ahí la respuesta se pierde por completo. (Es una medición de tokenización, determinista y reproducible; no usa GPU.)
¿Cómo lo descubrimos si no había ningún error?
No lo vio ningún usuario ni lo disparó ninguna excepción. Lo encontró una prueba de garantías que corrimos nosotros mismos sobre el verificador público, sin modificar su código ni sus umbrales. La prueba es simple y conviene copiarla: tomar pares (misma pregunta, respuesta correcta frente a incorrecta), alargar el contexto con un texto de relleno previo y comprobar que el puntaje todavía distingue una respuesta de otra.
Con 57 pares cuyo contexto tenía una mediana de 3.867 tokens, el resultado fue:
- Respuesta correcta con mejor puntaje: 0 de 57.
- Empate exacto: 57 de 57.
- Errores o advertencias: 0.
Esa combinación (resultados idénticos y cero errores) es el modo de falla de un evaluador que no avisa: un juez que devuelve un número siempre, tenga o no información para devolverlo.
Una aclaración importante para no exagerar el alcance. Las métricas que aparecen en la tarjeta del modelo se midieron con entradas de longitud estándar, que caben en la ventana, y no se ven afectadas. El fallo solo existía para entradas que superan max_length. Pero el código público estaba a un if de distancia de dar basura a cualquiera que le pasara un documento largo, y eso es lo que corregimos.
¿Por qué cortar por la izquierda no es la solución?
La primera idea, y la que enviamos como parche inicial, fue la más corta: truncation_side="left". Si la cola es lo que importa, se corta la cabeza. Una línea de código y los empates desaparecen.
Lo medimos y estaba mal. Cortar por la izquierda elimina la cabecera de la plantilla, la instrucción y las opciones de respuesta (que en muchas preguntas de opción múltiple están al principio). Sin empates, pero tampoco con criterio: la respuesta correcta puntuó más alto en solo 24 de 57 pares (42 %). Nuestro primer informe interno incluso citaba una cifra mejor (44 de 57) que no era de ese parche sino de otra variante; la corregimos el mismo día. Documentamos la corrección porque el error de atribuir a un arreglo el resultado de otro es el mismo tipo de descuido que estamos describiendo.
El principio correcto es otro: no recortes el texto ensamblado, recorta la parte que puedes sacrificar antes de ensamblarlo. En esta plantilla, hay cuatro piezas con valor muy distinto:
| Pieza | Valor para el vector final | ¿Se puede recortar? |
|---|---|---|
| Cabecera de la plantilla | Alto (define la tarea) | No |
| Material de la pregunta | Alto al principio (instrucciones, opciones), menor en la cola | Sí, la cola |
| Respuesta | Máximo (es lo que se juzga) | No |
| Pregunta final | Máximo (ahí se lee el vector) | No |
¿Cómo es el arreglo, línea por línea?
Antes de llenar la plantilla, se calcula cuántos tokens consumen las piezas fijas y la respuesta, y se le deja a la pregunta solo el presupuesto restante. Esta es una versión reducida de la función _fit que publicamos (la versión pública tiene además el caso en que la respuesta sola ocupa toda la ventana):
import json, urllib.request from transformers import AutoTokenizer REPO = "FINAL-Bench/ZTC-Judge-27B" tok = AutoTokenizer.from_pretrained(REPO) cfg = json.load(urllib.request.urlopen( f"https://huggingface.co/{REPO}/raw/main/ztc_config.json")) TPL, MAX_LEN = cfg["template"], cfg["max_length"] def n_tokens(s, special=True): return len(tok(s, add_special_tokens=special)["input_ids"]) def fit(question: str, answer: str, margin: int = 8) -> str: full = TPL % (question, answer) if n_tokens(full) <= MAX_LEN: # entrada corta: no se toca nada return full head, mid, tail = TPL.split("%s") fixed = sum(n_tokens(p, False) for p in (head, mid, tail)) a_ids = tok(answer, add_special_tokens=False)["input_ids"] q_ids = tok(question, add_special_tokens=False)["input_ids"] keep = MAX_LEN - fixed - len(a_ids) - margin if keep <= 0: raise ValueError("la respuesta sola no cabe en la ventana") while True: # al re-tokenizar pueden sobrar unos tokens out = TPL % (tok.decode(q_ids[:keep]), answer) if n_tokens(out) <= MAX_LEN or keep <= 0: return out keep -= 16 Tres decisiones merecen explicación:
- Entradas cortas, intactas. Si todo cabe,
fitdevuelve exactamente lo que habría producido la plantilla. Así se garantiza que no cambia ningún puntaje que ya habíamos medido. -
keepcomo presupuesto, no como corte. Se cuenta primero lo que no se puede sacrificar (plantilla fija: 40 tokens con este tokenizador; respuesta: 5 o 6 tokens en nuestro ejemplo) y la pregunta recibe el resto. - El bucle
while. Decodificar y volver a tokenizar puede cambiar el recuento en el borde de una palabra. Se reduce el recorte de 16 en 16 hasta que cabe. En la práctica, con una pregunta de 3.867 tokens, el resultado final tiene 2.040 tokens (cabe en 2.048), con 1.995 tokens de pregunta conservados, y termina en la pregunta final.
¿Qué dicen los números después del arreglo?
La misma medición interna (57 pares, contexto largo) comparó varias formas de ajustar la entrada:
| Estrategia | Pares con la correcta por encima (de 57) |
|---|---|
| Original (corte por la derecha) | 0 (57 empates) |
| Corte por la izquierda (primer parche, descartado) | 24 |
| Quitar el centro de la pregunta | 38 |
| Conservar la cabecera y recortar la cola (publicado) | 43 |
Y el desglose del resultado final frente al original:
Cinco matices que no queremos esconder:
- 43 de 57 no es 57 de 57. El arreglo devuelve la señal, no la devuelve entera. En los 14 pares restantes la respuesta incorrecta puntuó más alto. Recortar la cola de una pregunta pierde información, y a veces esa información era la relevante. Es la parte irreductible del problema: un modelo con una ventana de 2.048 tokens no puede leer 3.867.
- El conjunto es pequeño y lo usamos también para elegir. Con 57 pares, las diferencias entre 38 y 43 no son concluyentes. Elegimos "conservar cabecera" porque la lógica de la plantilla lo favorece y porque la medición no lo contradice, no porque 43 sea estadísticamente mejor que 38.
- La estrategia asume que las instrucciones y opciones están al principio. Si en tu formato las opciones van al final de un documento largo, recortar la cola las perdería. En nuestra medición interna, una variante estructurada del mismo método dio 46 de 57; la versión publicada es la general y dio 43.
- Son cifras nuestras. Medimos con nuestro propio banco de pares, en nuestra infraestructura. No las ha recalculado un tercero, y no deben leerse como una validación externa.
- Qué sí está confirmado y puedes comprobar tú mismo: el código corregido está en el repositorio público, y la prueba de colisión de
input_idsde más abajo es determinista.
¿Qué repositorios cambiaron y qué quedó fuera?
El 2 de noviembre de 2026 publicamos el arreglo en los repositorios públicos de la familia de verificadores:
| Repositorio | Cambio publicado |
|---|---|
FINAL-Bench/ZTC-Judge-27B | _fit en ztc_judge.py y usage_curve.py (commit 800940e) |
FINAL-Bench/ZTC-Judge-9B | Se añade el scorer corregido (commit 5d9843b) |
FINAL-Bench/ZTC-Judge-4B | Se añade el scorer corregido (commit b6e0a54) |
Cada tarjeta incluye ahora una entrada de historial de cambios: las entradas por encima de max_length se recortaban por la derecha, lo que eliminaba la respuesta al final de la plantilla y hacía que respuestas correctas e incorrectas puntuaran igual; el verificador ahora reduce la pregunta y conserva la respuesta y la pregunta final.
Otros modelos de la familia ZTC que se sirven con un servidor propio rechazan con un error explícito (422) las entradas que superan 32.768 tokens, así que fallan de forma ruidosa y no silenciosa. Es, por cierto, el comportamiento que preferimos: fallar fuerte es mejor que devolver un número plausible.
¿Cómo comprobar si tu verificador tiene el mismo fallo?
Tres pruebas, de menos a más importantes. Todas funcionan con cualquier modelo que ensamble una plantilla y la pase por un tokenizador con truncation=True.
Prueba 1: ¿sobrevive el final de la plantilla?
def tail_survives(question: str, answer: str) -> bool: ids = tok(TPL % (question, answer), truncation=True, max_length=MAX_LEN)["input_ids"] return answer in tok.decode(ids) Prueba 2: ¿dos respuestas distintas producen entradas distintas?
def same_input_for_different_answers(question, a, b) -> bool: enc = lambda x: tok(TPL % (question, x), truncation=True, max_length=MAX_LEN)["input_ids"] return enc(a) == enc(b) # True = tu verificador no ve la respuesta Ejecutadas sobre el código original, con una pregunta de relleno de 1.400, 2.400 y 3.800 tokens, devuelven (tail_survives, same_input...): True / False, False / True y False / True. Ejecutadas sobre fit con 1.400, 3.800 y 6.000 tokens, la respuesta sobrevive y las entradas difieren en los tres tamaños.
Prueba 3: ¿el arreglo es neutro en entradas cortas?
short = "Which compound is released?" assert fit(short, "C. Allomone") == TPL % (short, "C. Allomone") Si tu pipeline tiene puntajes ya publicados, esta prueba es la que protege tus métricas anteriores. Añade además un caso con pytest parametrizado por longitud (por ejemplo 500, 2.000, 2.100, 4.000 y 8.000 tokens) y exige que el puntaje de dos respuestas distintas difiera siempre. Un test de regresión que solo prueba entradas cortas habría pasado siempre en nuestro caso.
Preguntas frecuentes
¿Un truncation=True sin más es siempre un error?
No. Es seguro cuando lo que se pierde por la derecha es información de bajo valor. Es un error cuando la plantilla coloca al final lo que decide el resultado: la respuesta, una pregunta final, un token de clasificación. Revisa dónde va lo importante antes de elegir el lado.
¿Por qué no se cuenta como un fallo del modelo?
Porque el modelo hizo exactamente lo que se le pidió: leyó la entrada que recibió. El fallo estaba en el preprocesado, que decidió en silencio qué parte de la entrada existía. Los fallos de este tipo no aparecen en el benchmark, porque el benchmark usa entradas que caben.
¿Afecta a los resultados publicados de ZTC-Judge?
Las métricas de la tarjeta se midieron con entradas de longitud estándar y no cambian con el arreglo. El cambio afecta a quien use el verificador con textos largos, que era el caso en que el código antiguo no era fiable.
¿Por qué no ampliar max_length y ya?
Subirlo cambia el régimen con el que se midieron las métricas publicadas y el consumo de memoria, y exigiría volver a medir todo. Recortar la pregunta mantiene ese régimen tal cual.
¿Qué pasa si la respuesta sola es más larga que la ventana?
En el código público, se conserva su cola (la parte más cercana a la pregunta final) y se deja la pregunta vacía. Mejor eso que perder la respuesta por completo, aunque en ese régimen conviene tratar el puntaje como poco fiable y registrarlo.
¿Cómo evito que me pase en otro componente?
Haz que el recorte falle ruidosamente o deje rastro: registra cuántos tokens se descartaron en cada llamada y alerta si el porcentaje de entradas recortadas sube de cero. Un contador de truncados cuesta una línea y habría revelado este fallo el primer día.
Conclusión
Un verificador que devuelve el mismo puntaje para una respuesta buena y para una mala sin lanzar un error no es un verificador roto de forma visible, y por eso es peligroso. La causa fue un preprocesado con un valor por defecto razonable y una plantilla que colocaba lo importante al final. El arreglo es recortar lo sacrificable antes de ensamblar, comprobar que lo imprescindible sobrevive y medir con entradas más largas que las del benchmark. Nos equivocamos una vez en el camino (el corte por la izquierda) y lo dejamos documentado. Si tu sistema usa plantillas con la decisión al final, ejecuta las tres pruebas hoy.
Declaración: este artículo lo publica VIDRAFT sobre su propio código. Las cifras de pares (0, 24, 38, 43, 46 de 57) son mediciones internas autorreportadas sobre un conjunto pequeño; la tabla de tokens se obtuvo con el tokenizador público y es reproducible.
