Módulo 1: Qué cambia cuando un componente es no determinista

El contrato probabilístico

Descripción

Cuando escribes una función normal, tienes con ella un contrato: un acuerdo de qué te va a dar. normalize_sku(" wh 1000 xm5 ") te da "WH-1000-XM5", hoy y en un año, en tu máquina y en la del compañero. Ese contrato es tan firme que lo escribes como un test: assert normalize_sku(x) == "WH-1000-XM5". Si algún día falla, sabes que algo se rompió, y eso es exactamente lo que quieres de un test. Todo tu instinto de ingeniería —testear, verificar, confiar— está construido sobre ese contrato de igualdad exacta.

Un LLM no te ofrece ese contrato. Con el mismo input te puede dar salidas distintas, todas válidas, ninguna idéntica a la anterior. No es un bug: es cómo funciona un modelo probabilístico. Y eso significa que assert ai_component(x) == "salida esperada" es una prueba rota de nacimiento: va a fallar no cuando algo se rompa, sino cuando el modelo haga exactamente su trabajo. Esta lección desarma ese choque y lo repara. Vas a ver el assert clásico romperse en pantalla, entender por qué el contrato de un LLM no puede ser la igualdad exacta, y construir el contrato que sí funciona: verificar propiedades e invariantes de la salida, no su valor literal.

Conexión con el módulo. La lección 1 instaló la tesis —un LLM no es una función normal—. Esta lección la vuelve concreta en la propiedad más básica y más traicionera: la no-determinación, y qué le hace a la forma en que pruebas y confías en el componente. Es la base de casi todo lo que sigue: el eval del módulo 3 es este contrato-por-propiedades escalado a un conjunto de casos con un umbral; el guardrail del módulo 4 es este contrato aplicado en la frontera para rechazar salidas inválidas; y la cáscara determinista del módulo 6 es lo que ejecuta estas verificaciones antes de dejar que la salida toque el sistema. Aquí solo lo instalamos en su forma mínima. La frontera con AI Engineering sigue firme: no vamos a enseñar cómo hacer que el modelo sea más consistente (eso es prompting, temperature, técnicas de decodificación —territorio de AI Eng—); vamos a diseñar el sistema que convive con la no-determinación que el modelo tiene.

Una analogía: el pedido de "lo de siempre" en el café

Vas a dos lugares a desayunar.

El primero es una máquina expendedora. Aprietas el botón B4 y cae una barra de granola, exactamente la misma, cada vez. Aprietas B4 mil veces y son mil barras idénticas. Si un día cae otra cosa, la máquina está descompuesta, y con razón exiges que la arreglen. Tu contrato con la máquina es la igualdad exacta: B4 → esa barra, siempre.

El segundo es una cafetería con un barista. Le dices "lo de siempre, un latte". Te lo prepara. Vuelves mañana y le pides "lo de siempre": otra vez un latte, bueno, pero el dibujo en la espuma es distinto, la temperatura varía un grado, quizá lo sirve en otra taza. ¿Está descompuesto el barista porque el latte de hoy no es idéntico al de ayer? Por supuesto que no. Sería absurdo devolverle el café gritando "¡esto no es byte-por-byte igual al de ayer!". Tu contrato con el barista nunca fue la igualdad exacta. Tu contrato es otro, y lo tienes clarísimo aunque nunca lo hayas escrito: que sea un latte (no un té), que esté caliente (no frío), que quepa en la taza (no derramándose), que no tenga sal en vez de azúcar. Verificas propiedades, no un valor exacto. Si el latte cumple esas propiedades, el barista hizo su trabajo, por más que cada taza sea un poco distinta.

Aquí está el punto: una función determinista es la máquina expendedora; un LLM es el barista. El error no es que el barista varíe —eso es su naturaleza—; el error es exigirle el contrato de la máquina expendedora. Cuando escribes assert ai_component(x) == "valor exacto", le estás gritando al barista que su latte de hoy no es idéntico al de ayer. El arreglo no es "hacer que el barista sea una máquina" (perderías justo lo que lo hace valioso); es escribir el contrato correcto —verificar que sea un latte, caliente, en la taza, sin sal— que es el contrato que de verdad te importa. Esta lección escribe ese contrato en código.

Ejemplo trabajado: el assert exacto se rompe, el contrato por propiedades aguanta

Vamos a poner lado a lado una función determinista y un ai_component simulado, y a ver qué contrato aguanta con cada uno. El ai_component es un stub: no llama a ningún modelo real, no toca la red, no usa claves. Simula lo único que nos importa aquí —que con el mismo input devuelve textos distintos—, entregando paráfrasis equivalentes en un orden barajado una vez con una semilla fija, así el resultado es reproducible y a la vez distinto en cada llamada, tal como haría un LLM real con temperature > 0.

# Lección 2: el contrato probabilístico.
# Contrasta una función determinista (assert exacto) contra un
# ai_component SIMULADO por un stub (misma entrada -> salidas distintas).
import random

# --- Semilla fija: reproducible entre corridas. ---
_RNG = random.Random(42)

# --- Parte A: una función normal, determinista. ---
def normalize_sku(raw):
    # Misma entrada -> misma salida, SIEMPRE. Testeable con assert exacto.
    return raw.strip().upper().replace(" ", "-")


# --- Parte B: un componente de IA, SIMULADO (nada de red ni API real). ---
# Un LLM real con temperature > 0 devuelve textos distintos para la misma
# entrada: aquí lo imitamos entregando paráfrasis equivalentes en un orden
# barajado una vez con la semilla fija (reproducible, y distinto por llamada).
_PARAPHRASES = [
    "Auriculares inalambricos con cancelacion de ruido",
    "Audifonos bluetooth con cancelacion activa de ruido",
    "Auriculares BT con noise cancelling y estuche de carga",
    "Cascos inalambricos con cancelacion de ruido y microfono",
]
_RNG.shuffle(_PARAPHRASES)
_call_index = 0


def ai_component(product_review):
    # SIMULA un LLM que resume una reseña en un título corto de producto.
    # Ignora el contenido y varía la salida en cada llamada: eso es el punto.
    global _call_index
    text = _PARAPHRASES[_call_index % len(_PARAPHRASES)]
    _call_index += 1
    return text


REVIEW = "me encantaron, aislan muchisimo el ruido del metro"

print("=== Funcion determinista: mismo input -> mismo output ===")
out1 = normalize_sku("  wh 1000 xm5 ")
out2 = normalize_sku("  wh 1000 xm5 ")
print(f"llamada 1: {out1!r}")
print(f"llamada 2: {out2!r}")
assert out1 == out2 == "WH-1000-XM5"  # pasa: el contrato es la igualdad exacta
print("assert out1 == out2 == 'WH-1000-XM5'  -> PASA")

print()
print("=== ai_component: mismo input -> outputs DISTINTOS ===")
outs = [ai_component(REVIEW) for _ in range(3)]
for i, o in enumerate(outs, 1):
    print(f"llamada {i}: {o!r}")

print()
print("=== El assert clasico se rompe ===")
try:
    assert outs[0] == outs[1]
    print("assert outs[0] == outs[1] -> PASA")
except AssertionError:
    print("assert outs[0] == outs[1] -> AssertionError (como esperabamos)")

print()
print("=== El contrato que lo reemplaza: propiedades, no igualdad ===")
def satisfies_contract(text):
    # No afirmamos QUE dice; afirmamos que cumple invariantes verificables.
    return (
        isinstance(text, str)
        and 0 < len(text) <= 80          # no vacio y acotado
        and "\n" not in text             # una sola linea
        and not any(w in text.lower() for w in ("http", "<script"))  # sin ruido
    )

for i, o in enumerate(outs, 1):
    ok = satisfies_contract(o)
    print(f"llamada {i}: contrato={'OK' if ok else 'FALLA'}")
assert all(satisfies_contract(o) for o in outs)
print("assert all(satisfies_contract(o) for o in outs) -> PASA")

Qué esperar. Al correr el archivo, la salida es exactamente esta:

=== Funcion determinista: mismo input -> mismo output ===
llamada 1: 'WH-1000-XM5'
llamada 2: 'WH-1000-XM5'
assert out1 == out2 == 'WH-1000-XM5'  -> PASA

=== ai_component: mismo input -> outputs DISTINTOS ===
llamada 1: 'Auriculares BT con noise cancelling y estuche de carga'
llamada 2: 'Audifonos bluetooth con cancelacion activa de ruido'
llamada 3: 'Cascos inalambricos con cancelacion de ruido y microfono'

=== El assert clasico se rompe ===
assert outs[0] == outs[1] -> AssertionError (como esperabamos)

=== El contrato que lo reemplaza: propiedades, no igualdad ===
llamada 1: contrato=OK
llamada 2: contrato=OK
llamada 3: contrato=OK
assert all(satisfies_contract(o) for o in outs) -> PASA

Lee la salida por partes, porque cada bloque es una pieza del argumento.

El primer bloque es la máquina expendedora. normalize_sku recibe el mismo input dos veces y devuelve 'WH-1000-XM5' las dos. El assert de igualdad exacta pasa, y pasa porque debe pasar: para una función determinista, ese es el contrato correcto. Si un día fallara, sabrías que hay un bug real. Guarda esta sensación de solidez, porque es justo la que un LLM te quita.

El segundo bloque es el barista. El mismo REVIEW entra tres veces y salen tres títulos distintos —los tres describen los mismos audífonos con cancelación de ruido, ninguno es idéntico a otro—. Nada se rompió; el ai_component hizo exactamente lo que un modelo probabilístico hace.

El tercer bloque es el choque. assert outs[0] == outs[1] —el reflejo que traes de testear código normal— lanza AssertionError. Y fíjate en la trampa: no falló porque el componente esté mal, falló porque el componente es no determinista y tu contrato era el equivocado. Un equipo que no entienda esto va a "arreglar" el test copiando la última salida al valor esperado, y va a volver a fallar en la siguiente corrida, para siempre.

El cuarto bloque es la reparación. satisfies_contract no pregunta qué dice la salida; pregunta si cumple invariantes: que sea un string, no vacío y de a lo más 80 caracteres, de una sola línea, y sin ruido peligroso (nada de http ni <script). Las tres salidas distintas pasan las tres, y el assert all(...) pasa. Ese es el contrato probabilístico: no afirmas el valor, afirmas las propiedades. Es exactamente el contrato del barista —que sea un latte, caliente, en la taza, sin sal— escrito en código.

Profundización: de la igualdad exacta al contrato por propiedades

Vale la pena hacer explícito el cambio de forma mental, porque es el que sostiene toda la guía.

Un contrato es una afirmación sobre la salida. Con una función determinista, la afirmación más fuerte posible es la igualdad exacta: "la salida es este valor". Es la más fuerte porque no deja nada sin especificar. Con un LLM no puedes hacer esa afirmación —la salida no es un valor fijo—, así que retrocedes a afirmaciones más débiles pero verdaderas: "la salida tiene estas propiedades". No es una derrota; es el contrato correcto para un componente probabilístico, igual que "que sea un latte caliente" es el contrato correcto para un barista.

Las propiedades típicas que un contrato de LLM verifica —y que vas a ver una y otra vez en la guía— caen en unas pocas familias:

Familia de propiedad     Ejemplo de invariante                    Módulo que lo usa
───────────────────────  ───────────────────────────────────────  ─────────────────
Estructura / formato     es JSON valido; tiene las claves X, Y     M4 (guardrail)
Longitud / forma         no vacio; <= N caracteres; una linea      M4 (guardrail)
Contenido permitido      no contiene claims/palabras prohibidas    M4 (guardrail)
Pertenencia a un conjunto la categoria esta en el catalogo valido  M6 (cascara)
Calidad agregada         >= 80% de un eval-set pasa el umbral      M3 (eval gate)

Fíjate en la última fila, porque marca una frontera importante. Las cuatro primeras familias se verifican por salida individual: cada salida, sola, cumple o no cumple. La quinta es distinta: la calidad de un LLM no se juzga bien salida por salida (una salida individual "buena" o "mala" es subjetiva y varía), sino en agregado sobre un conjunto de casos. "El 87% de los 50 casos del eval-set pasan el umbral" es una afirmación que sí puedes usar como compuerta, y es exactamente lo que el módulo 3 construye. Aquí quédate con la intuición: el contrato individual (propiedades por salida) y el contrato agregado (eval sobre un conjunto) son dos capas del mismo cambio de mirada —de "afirmo el valor" a "afirmo propiedades medibles"—.

Por qué esto no es "testear menos". Podría parecer que verificar propiedades en vez del valor exacto es una prueba más floja, y por lo tanto peor. Es al revés. La igualdad exacta es un contrato frágil para un LLM: pasa por casualidad (cuando el modelo repite una salida) y falla por casualidad (cuando varía), sin correlación con si el componente está bien. El contrato por propiedades es robusto: pasa cuando la salida sirve y falla cuando no sirve, que es justo lo que un test debe hacer. Cambiar de igualdad a propiedades no es bajar el listón; es poner el listón donde de verdad importa.

Dónde vive este contrato en el sistema. Un detalle que la lección 3 desarrolla: la función satisfies_contract no es parte del modelo —es parte de la cáscara determinista que rodea al modelo—. El LLM propone la salida; una función determinista (esta) verifica sus propiedades antes de que el sistema la use. Es determinista, es testeable con assert exacto (sus propias entradas y salidas sí son fijas), y es tu punto de control. El núcleo probabilístico produce; la cáscara determinista verifica. Ya lo estás viendo en miniatura.

Errores comunes

Fijar la salida esperada al valor que devolvió el modelo la primera vez. Qué pasa: escribes assert summarize(review) == "Auriculares con cancelación de ruido" copiando lo que el modelo dijo hoy, el test pasa, lo comiteas. Mañana el modelo dice otra cosa igual de válida y el test falla; lo "arreglas" copiando la nueva salida; pasado mañana vuelve a fallar. Por qué pasa: el reflejo de testear código determinista es fijar el valor esperado, y ese reflejo es correcto para código normal y venenoso para un LLM. Cómo detectarlo: tienes tests de IA que "arreglas" pegando la última salida del modelo, y vuelven a romperse solos. Cómo corregirlo: nunca afirmes el valor exacto de una salida de LLM. Afirma propiedades —formato, longitud, contenido permitido, pertenencia a un conjunto—. Si de verdad necesitas comparar contra referencias, eso es un eval con un umbral en agregado (módulo 3), no un assert == por caso.

Confundir "no determinista" con "roto" (o con "hay que hacerlo determinista a la fuerza"). Qué pasa: un ingeniero ve que el mismo input da salidas distintas y concluye que el componente está mal, o se obsesiona con forzar temperature=0 y semillas para que "siempre dé lo mismo", peleando contra la naturaleza del modelo. Por qué pasa: la no-determinación se siente como una falla porque contradice todo lo que sabemos de software determinista. Cómo detectarlo: tu plan para "estabilizar" la IA es hacerla repetir la misma salida, en lugar de contener su variación. Cómo corregirlo: acepta la variación como un hecho del componente y diséñala para que no importe. Aun con temperature=0 un LLM no garantiza determinismo byte-por-byte entre versiones del modelo o infraestructuras, así que tu robustez no puede depender de eso. El objetivo no es que el barista sirva tazas idénticas; es que cada taza cumpla el contrato. (Cómo ajustar el modelo en sí es AI Engineering; aquí diseñamos el sistema que convive con lo que el modelo es.)

Verificar solo que "no está vacío" y llamarlo contrato. Qué pasa: el equipo agrega una única verificación —assert len(output) > 0— y siente que ya "validó" la salida del LLM. Por qué pasa: es la propiedad más fácil de verificar, así que se toma como suficiente. Cómo detectarlo: tu contrato de salida tiene una sola condición trivial, y salidas claramente malas (un JSON malformado, un texto de 5000 caracteres, una respuesta con un <script>) pasarían igual. Cómo corregirlo: un contrato útil verifica varias familias de propiedades relevantes para esa salida: estructura/formato si esperas JSON, límite de longitud si va a una UI, contenido permitido si el usuario la va a ver, pertenencia a un conjunto si debe ser un valor de un catálogo. La lección 4 lleva esto al detalle como guardrail en la frontera; aquí basta con que "no vacío" es el piso, no el contrato.

Ejercicios

Ejercicio 1 — Escribe el contrato. Mercado usa un ai_component para clasificar cada producto nuevo en una de estas categorías: {"audio", "computo", "hogar", "moda", "libros"}. La salida debe ser exactamente una de esas cinco cadenas. Escribe la función satisfies_contract(output) que verifica el contrato correcto, y explica por qué un assert classify(product) == "audio" sería el contrato equivocado.

Ver solución

El contrato correcto es pertenencia a un conjunto: la salida debe ser uno de los cinco valores válidos.

VALID_CATEGORIES = {"audio", "computo", "hogar", "moda", "libros"}


def satisfies_contract(output):
    return isinstance(output, str) and output in VALID_CATEGORIES

Por qué assert classify(product) == "audio" es el contrato equivocado: fija la salida a un valor específico, pero el punto de un clasificador es que distintos productos caen en distintas categorías, y aun para el mismo producto el modelo podría dudar entre dos categorías válidas en llamadas distintas. Afirmar == "audio" prueba una sola cosa (que este producto en particular dio "audio" esta vez), es frágil, y no captura lo que de verdad importa: que la salida sea siempre una categoría válida del catálogo, sea cual sea. Si el modelo devolviera "electrónica" o "" o "audio, computo", el contrato por pertenencia lo atrapa; el assert == a un valor fijo no. Este es exactamente el contrato que la cáscara determinista del módulo 6 usa para contener al clasificador.

Ejercicio 2 — ¿Qué familia de propiedad? Para cada salida de LLM en Mercado, di qué familia de propiedad (estructura/formato, longitud/forma, contenido permitido, pertenencia a un conjunto, calidad agregada) es la más importante de verificar, y da un ejemplo de la invariante concreta: (a) el JSON que el agente de soporte propone con {"order_id", "amount"}; (b) el título corto de producto que se muestra en una tarjeta de la UI; (c) la descripción generada que el vendedor va a publicar; (d) saber si una nueva versión del prompt de búsqueda es mejor o peor que la anterior.

Ver solución
  • (a) JSON del agente → estructura/formato. Invariante: "es un objeto JSON válido, tiene exactamente las claves order_id (str) y amount (número ≥ 0)". Sin esta verificación, la cáscara no podría siquiera leer la propuesta. Es el primer filtro antes de validar la política de reembolso.
  • (b) Título en una tarjeta → longitud/forma. Invariante: "no vacío, ≤ 60 caracteres, una sola línea". Un título de 400 caracteres rompería el diseño de la tarjeta aunque el texto sea correcto. La forma importa porque va a un espacio visual acotado.
  • (c) Descripción a publicar → contenido permitido. Invariante: "no contiene claims prohibidos (cura, 100% garantizado, el mejor del mundo) ni datos de contacto externos". Como el usuario final la va a ver publicada, el riesgo es el contenido, no la forma.
  • (d) ¿El nuevo prompt es mejor? → calidad agregada. Invariante: "el score del eval-set (por ejemplo, recall@10 sobre 50 consultas etiquetadas) no baja del umbral". Esta no se verifica por salida individual, sino sobre un conjunto de casos —es la compuerta de eval del módulo 3—. Las otras tres se verifican salida por salida; esta, en agregado.

Ejercicio 3 — El test que se arregla solo (y por qué está mal). Un compañero tiene este test para el generador de descripciones y se queja de que "falla como una de cada tres veces sin razón":

def test_description():
    out = ai_component({"type": "headphones", "battery_h": 30})
    assert out == "Audifonos con 30 h de bateria"

Explica exactamente por qué falla de forma intermitente, por qué "copiar la última salida al valor esperado" no lo arregla, y reescribe el test con el contrato correcto.

Ver solución

Por qué falla intermitente: el ai_component es no determinista, así que con el mismo input devuelve textos distintos —"Audífonos con 30 h de batería", "Auriculares con 30 horas de autonomía", "Audífonos livianos, batería de 30 h"—, todos válidos. El assert out == "Audifonos con 30 h de bateria" solo pasa las veces que el modelo, por azar, produce ese string exacto; el resto de las veces falla. No hay ninguna "razón" ligada a un bug: el test está midiendo la propiedad equivocada.

Por qué copiar la última salida no lo arregla: cambiar el valor esperado a la última salida que dio el modelo solo mueve cuál es la salida "afortunada" que pasa. La siguiente corrida vuelve a producir otra variante y vuelve a fallar. Es una rueda de hámster: el problema no es qué valor esperas, es que esperes un valor.

El test con el contrato correcto —verificar propiedades, no igualdad—:

def test_description():
    out = ai_component({"type": "headphones", "battery_h": 30})
    assert isinstance(out, str)
    assert 0 < len(out) <= 200          # no vacio y acotado
    assert "\n" not in out              # una sola linea
    assert "30" in out                  # menciona el dato clave del input
    banned = ("cura", "100% garantizado", "el mejor del mundo")
    assert not any(b in out.lower() for b in banned)   # sin claims prohibidos

Este test pasa para cualquier descripción válida —varíe como varíe— y falla solo cuando la salida de verdad no sirve (vacía, larguísima, con un claim prohibido, o que ni menciona los 30 h). Ese es el contrato probabilístico: afirmar propiedades que capturan lo que importa, no un valor literal que el modelo no tiene por qué repetir.

Resumen y siguiente paso

En esta lección viste, ejecutado, el corazón de por qué un LLM no es una función normal: con el mismo input da salidas distintas, y el assert de igualdad exacta se rompe —no cuando algo falla, sino cuando el modelo hace su trabajo—. Entendiste que el contrato correcto no es afirmar el valor de la salida sino verificar sus propiedades e invariantes: estructura, longitud, contenido permitido, pertenencia a un conjunto, y —en agregado— calidad sobre un eval-set. Y viste que ese cambio no es testear menos, sino testear donde de verdad importa: el contrato por propiedades pasa cuando la salida sirve y falla cuando no, que es justo el trabajo de un test. Como el barista: no exiges tazas idénticas, exiges que cada taza sea un latte caliente en la taza y sin sal.

Antes de avanzar deberías poder: explicar por qué assert ai_component(x) == valor es un contrato roto de nacimiento; nombrar al menos tres familias de propiedades que un contrato de LLM verifica; distinguir el contrato individual (por salida) del agregado (eval sobre un conjunto); y reescribir un test de igualdad exacta como un test por propiedades.

La lección 3 toma la función satisfies_contract que acabas de escribir y muestra dónde vive en el sistema. Porque verificar la salida es solo la mitad del cuadro: la otra mitad es que el LLM propone y una capa determinista dispone. Vas a ver, ejecutado, el antipatrón en el que el modelo ejecuta su propia salida y "reembolsa" $9999 alucinados, contra el patrón en el que la cáscara determinista valida la propuesta contra la política de Mercado y la bloquea. El LLM como componente tras una frontera, no como el sistema entero.

Recursos

  • Martin Fowler y Bharani Subramaniam, "Emerging Patterns in Building GenAI Apps" — martinfowler.com/articles/gen-ai-patterns. Trata evals y verificación de salida como patrones de primera clase. El apartado sobre evaluar salidas no deterministas es el complemento directo de esta lección. En inglés.
  • Chip Huyen, AI Engineering (O'Reilly, 2024), capítulos de evaluación. La distinción entre verificar una salida individual y evaluar calidad en agregado —el salto de esta lección al módulo 3— está desarrollada ahí. Nosotros nos quedamos con la capa de diseño; el detalle de cómo construir el eval-set es la frontera con AI Eng. En inglés.
  • Documentación de Claude — docs.anthropic.com. Consulta la sección sobre por qué las salidas varían y qué controlan parámetros como temperature, sin fijarte en una versión de modelo. Útil para entender de dónde viene la no-determinación que esta lección diseña para contener. En inglés.
  • Anthropic, "Building Effective Agents" (2024) — anthropic.com/engineering/building-effective-agents. Refuerza la idea de verificar y acotar lo que el componente de IA produce antes de actuar sobre ello, que es el puente de esta lección a la siguiente. En inglés.