Curso / HF LLM Course / Capítulo 9
HF LLM Course · 9 de 12

Hugging Face · LLM Course · Capítulo 9

Demos con Gradio: de una función de Python a una UI compartible

Todo lo anterior asumía que interactuás con tu modelo desde código. Este capítulo es la capa que le pone una interfaz — sin escribir HTML, CSS ni JS.

Antes de leercap. 2 §5, servir con vLLM

🎯 Al terminar vas a poder

  • Envolver cualquier función de Python en una UI con gr.Interface, eligiendo el componente correcto para cada input y output.
  • Distinguir un link temporal (share=True, una semana) de un Space permanente, y saber cuál usar en cada caso.
  • Cargar una demo desde el Hub con gr.load() y explicar por qué para un LLM hoy conviene apuntar a un servidor de inferencia separado.
  • Escribir un gr.Blocks con dos componentes y un evento .click, y un gr.ChatInterface con streaming contra un servidor OpenAI-compatible (vLLM).
  • Diagnosticar las cuatro fallas típicas de una demo: tipo de retorno equivocado, modelo cargado en cada llamada, share=True en producción, puerto ocupado.

1. Por qué armar una demo

Una demo no es solo para mostrar — es una herramienta de trabajo: le permite a gente no técnica probar tu modelo sin tocar código, a vos encontrar fallos de forma interactiva mucho más rápido que revisando logs, y a cualquiera notar sesgos del modelo con inputs que vos nunca hubieras pensado probar.

Hay una cuarta razón, menos obvia: una demo es el contrato de entrada y salida de tu modelo hecho visible. Declarar "el input es un texto y la salida es una etiqueta con puntaje" te obliga a decidir el formato de salida antes de que alguien lo consuma — y ese formato es lo primero que vas a querer estabilizar cuando el modelo pase a producción.

2. Tu primera demo con Interface

Tres argumentos alcanzan para envolver cualquier función de Python en una UI web:

import gradio as gr

def saludar(nombre):
    return f"Hola {nombre}!"

gr.Interface(fn=saludar, inputs="text", outputs="text").launch()

fn es tu función de predicción; inputs/outputs declaran qué tipo de componente usar — texto, imagen, audio (como un array de NumPy), varios inputs o outputs a la vez. Los strings cortos ("text", "image", "audio") son atajos; la forma larga es instanciar el componente (gr.Textbox(lines=3, label="Prompt")) para controlar etiqueta, tamaño y valor inicial. Lo que fn devuelve tiene que ser del tipo que el componente de salida entiende:

ComponenteComo input, fn recibeComo output, fn devuelve
gr.Textboxstrstr
gr.Label—dict etiqueta → probabilidad (muestra las top-k como barras), o un str
gr.Imagearray NumPy (H, W, 3) por default; type="pil" para una imagen PILarray NumPy, imagen PIL o ruta a archivo
gr.Audiotupla (sample_rate, array) por defaulttupla (sample_rate, array) o ruta a archivo
gr.Number / gr.Sliderfloatfloat
gr.JSON—dict o list

Enchufar un modelo real es la misma idea, con fn llamando a un pipeline() del Capítulo 1 en vez de a un saludo. Fijate que el pipeline se crea una sola vez, fuera de fn: cargar el modelo es lo caro; fn se llama en cada click.

generator = pipeline("text-generation", model="HuggingFaceTB/SmolLM2-360M")   # una vez, al arrancar

def predict(prompt):
    return generator(prompt)[0]["generated_text"]   # en cada llamada: solo inferencia

gr.Interface(fn=predict, inputs="text", outputs="text").launch()

Si fn devuelve varias cosas, outputs es una lista de componentes y la función devuelve una tupla en el mismo orden. Un clasificador que además muestra las probabilidades:

clf = pipeline("sentiment-analysis")

def clasificar(texto):
    r = clf(texto, top_k=None)                      # todas las clases con su score
    return {d["label"]: d["score"] for d in r}  # gr.Label espera un dict etiqueta → prob

gr.Interface(fn=clasificar, inputs=gr.Textbox(lines=3, label="Reseña"), outputs=gr.Label(num_top_classes=2),
             title="Sentimiento", examples=[["Me encantó."], ["Una pérdida de tiempo."]]).launch()

3. Compartir: link temporal o Spaces

Link temporalHugging Face Spaces
Cómolaunch(share=True)Repo dedicado en huggingface.co/spaces
Dura1 semana (Gradio 4 y 5)Permanente
Dónde corre el modeloEn tu máquina — el link es un túnel hacia tu procesoEn la infraestructura de HF (CPU gratis, GPU paga)
ParaMandarle un link a alguien yaPublicar algo que la gente vaya a volver a usar

📜 Lo que decía el original

El curso original habla de un link de 72 horas. Eso era Gradio 3. Desde Gradio 4 el link share=True dura una semana — el propio Gradio lo imprime al arrancar: "This share link expires in 1 week". El mecanismo no cambió: es un túnel público hacia tu proceso local, así que si cerrás la terminal, el link muere aunque no haya pasado la semana.

Antes de compartir, vale la pena pulir la demo: título, descripción, ejemplos precargados de input — todos argumentos directos de Interface, sin trabajo extra. Y para servir en tu propia red (o dentro de un contenedor) sin pasar por el túnel de Gradio, fijás dónde escucha el servidor:

demo.launch(server_name="0.0.0.0", server_port=7860)   # accesible desde otras máquinas de la red / desde fuera del contenedor

server_name="0.0.0.0" hace que escuche en todas las interfaces (por default solo en 127.0.0.1, es decir, solo tu máquina); server_port fija el puerto en vez de dejar que Gradio busque uno libre a partir del 7860. Es la forma de exponer una demo en un servidor propio — detrás de un proxy inverso o una VPN, no directo a internet.

4. Cargar directo desde el Hub

gr.load("models/usuario/modelo") arma la demo completa a partir de un modelo del Hub sin que escribas la función de predicción vos — Gradio infiere la tarea y el tipo de input/output a partir de la metadata del repo, y las predicciones se hacen contra la API de inferencia de Hugging Face, no en tu máquina. Lo mismo funciona apuntando a un Space existente, que podés cargar y hasta reusar como pieza dentro de una demo más grande:

import gradio as gr

demo = gr.load("models/distilbert/distilbert-base-uncased-finetuned-sst-2-english")   # un modelo del Hub
demo.launch()

demo2 = gr.load("spaces/gradio/hello_world")   # un Space entero, con su UI
demo2.launch()

📜 Lo que decía el original

El curso muestra gr.Interface.load("huggingface/…") y Interface.load("spaces/…"). Esa forma existía en Gradio 3 y fue eliminada en Gradio 4. Hoy es una única función a nivel de módulo, gr.load(), con el prefijo models/ o spaces/ en el nombre.

🟢 Actualización 2026

Cargar el modelo directamente en el mismo proceso que la demo (lo que hace el pipeline() de la sección 2) funciona bien para modelos chicos. Para un LLM de verdad, el patrón de hoy es separar las dos cosas — la demo de Gradio solo hace requests HTTP a un servidor de inferencia ya corriendo (vLLM, TGI, Capítulo 2), en vez de cargar el modelo dentro del propio proceso de la UI. Mismo resultado para quien usa la demo, pero el modelo pesado vive en su propio proceso, reiniciable y escalable aparte. La sección 7 muestra exactamente ese patrón con gr.ChatInterface.

5. State e interpretación

Por default, cada llamada a fn es independiente — no recuerda nada de la anterior. gr.State le da persistencia entre interacciones dentro de la misma sesión: el ingrediente mínimo para armar un chatbot, que necesita recordar los turnos anteriores de la conversación. La mecánica: el estado entra como un input más y sale como un output más, y Gradio se encarga de guardar el valor devuelto para la próxima llamada de ese usuario (cada pestaña del navegador tiene su propio estado; no se comparte entre usuarios).

def contar(mensaje, historial):
    historial = historial + [mensaje]
    return f"Llevás {len(historial)} mensajes. El último: {mensaje}", historial

gr.Interface(fn=contar,
             inputs=["text", gr.State([])],      # el estado entra como input…
             outputs=["text", gr.State()]).launch()  # …y sale como output, actualizado

Sobre interpretación: el curso original mostraba interpretation="default" (o "shap") como argumento de Interface, que resaltaba qué partes del input pesaron más en la predicción. Esa opción existió en Gradio 3 y fue eliminada en Gradio 4. La idea sigue siendo valiosa — no tratar al modelo como caja negra ni siquiera en la demo — pero hoy la interpretación se calcula aparte, con una librería de atribución como Captum o SHAP, y se muestra con un componente que sepa pintar puntajes por token, como gr.HighlightedText, dentro de un gr.Blocks. En la práctica: tu fn devuelve, además de la predicción, una lista de pares (token, puntaje), y el componente los colorea.

6. Blocks: la API de bajo nivel

Interface alcanza para "una función, unos inputs, unos outputs". Para layouts custom, demos de varios pasos, o actualizar un componente en respuesta a otro, gr.Blocks da control total sobre filas, columnas y qué evento dispara qué función — es, literalmente, la capa sobre la que está construido cualquier componente de más alto nivel de Gradio, incluido el ChatInterface moderno para chatbots.

El ejemplo mínimo que muestra la diferencia de modelo mental — los componentes se declaran dentro de un with, y los eventos se conectan explícitamente:

import gradio as gr

def invertir(texto):
    return texto[::-1]

with gr.Blocks() as demo:
    entrada = gr.Textbox(label="Texto")
    salida  = gr.Textbox(label="Al revés")
    boton   = gr.Button("Invertir")
    boton.click(fn=invertir, inputs=entrada, outputs=salida)   # evento → función → componente

demo.launch()

Tres cosas que Interface hacía por vos y acá decidís vos: qué dispara la función (un botón, entrada.submit al apretar Enter, entrada.change en cada tecla), qué componentes son inputs y cuáles outputs (cualquiera puede ser ambos), y el layout (gr.Row() y gr.Column() como contextos anidados). Un mismo botón puede disparar varias funciones encadenadas con .then(), que es como se arman los flujos de "primero valido, después genero, después muestro".

🔧 Por qué tu propio proyecto no usa Gradio

La UI de interpretante-lacaniano está hecha en Streamlit, no Gradio — y la razón encaja con la distinción de esta sección. Gradio brilla envolviendo una función de predicción aislada rápido; tu proyecto necesita múltiples paneles con estado propio (rating 👍/👎 persistente, historial de diálogos, disparar el daemon generativo), más cerca de una aplicación completa que de una demo de un modelo. Ambos frameworks resuelven "UI en Python sin escribir JS" — la diferencia es cuánta estructura de app necesitás por encima de "mostrame la salida del modelo".

7. ChatInterface contra un servidor vLLM

El caso de uso más común de Gradio en 2026 es un chat sobre un LLM, y el patrón que recomienda la sección 4 — la UI en un proceso, el modelo en otro — se escribe en veinte líneas. Del lado del modelo, vLLM expone un servidor compatible con la API de OpenAI:

vllm serve google/gemma-4-E2B-it --port 8000   # en otra terminal, o en otro contenedor / máquina

Del lado de la UI, gr.ChatInterface solo necesita una función fn(message, history). Si esa función usa yield en vez de return, Gradio muestra el texto a medida que llega — streaming, sin configurar nada más. El cliente openai con base_url apuntando a vLLM hace el resto:

import gradio as gr
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")   # vLLM no valida la key por default
MODEL = "google/gemma-4-E2B-it"
SYSTEM = "Respondés en español rioplatense, corto y directo."

def responder(message, history):
    # con type="messages", history ya viene como lista de {"role", "content"} (puede traer claves extra: las filtramos)
    messages = [{"role": "system", "content": SYSTEM}]
    messages += [{"role": m["role"], "content": m["content"]} for m in history]
    messages.append({"role": "user", "content": message})

    stream = client.chat.completions.create(model=MODEL, messages=messages, stream=True, temperature=0.7)
    parcial = ""
    for chunk in stream:
        parcial += chunk.choices[0].delta.content or ""
        yield parcial          # cada yield reemplaza el mensaje en pantalla con el texto acumulado

demo = gr.ChatInterface(fn=responder, type="messages", title="Chat sobre vLLM")
demo.launch(server_name="0.0.0.0", server_port=7860)

Tres detalles que hacen que esto funcione y no sean obvios:

  • yield parcial, no yield delta: Gradio muestra lo último que yieldeaste, no lo concatena. Si yieldeás solo el fragmento nuevo, ves palabras sueltas parpadeando.
  • type="messages": hace que history llegue como lista de diccionarios con role/content, el mismo formato del chat template del Capítulo 11 y de la API de OpenAI. El formato viejo (lista de pares [usuario, bot]) sigue existiendo pero obliga a convertir.
  • El chat template lo aplica vLLM, no vos: le mandás mensajes con roles y el servidor los formatea con el template del modelo. Es la razón de fondo para no concatenar strings a mano.

Si el servidor se cae, la demo sigue viva y el próximo mensaje falla con un error de conexión — que es exactamente lo que querés: reiniciás vLLM sin tocar la UI. Y si mañana el modelo es otro, cambiás MODEL y nada más.

📐 Arquitectura de colas ASGI, protocolo SSE y métricas de latencia de servicio (TTFT vs. ITL)

En producción, exponer un LLM a través de Gradio involucra desacoplar la capa de transporte web de la GPU mediante un sistema de colas asíncronas basado en Starlette/FastAPI:

  • Protocolo SSE (Server-Sent Events): A diferencia de WebSockets que requiere un canal bidireccional pesado con framing binario, el streaming de tokens utiliza HTTP unidireccional con cabecera Content-Type: text/event-stream y Transfer-Encoding: chunked. Cada token generado en vLLM se envía como un chunk data: {"delta": "..."} . Gradio procesa el generador Python (yield) convirtiendo cada evento en una actualización reactiva del DOM del navegador.
  • Sistema de colas asíncronas (Asyncio Queue Pool): Si múltiples usuarios envían prompts simultáneamente a una función síncrona sin cola, el Global Interpreter Lock (GIL) de Python bloquea el servidor HTTP. Gradio implementa una cola de prioridad:
    Tespera=∑q=1QLqconcurrency_limit×ThroughputGPU
    donde concurrency_limit define cuántas peticiones pueden ejecutar el bucle de streaming en paralelo contra vLLM antes de encolar las siguientes en el buffer de espera.
  • Métricas críticas de evaluación en producción:
    • TTFT (Time-To-First-Token): Tiempo desde que el usuario pulsa Enviar hasta que el primer token del stream llega al navegador (Tred+Tprefill). Debe mantenerse <200 ms para una experiencia interactiva óptima.
    • ITL (Inter-Token Latency): Tiempo medio entre la emisión de dos tokens sucesivos durante la fase de decodificación autoregresiva (Tdecode=1tokens/segundo). Una velocidad de 30 a 50 tokens/s produce un ITL de 20 a 33 ms por token.
La UI en un proceso, el modelo en otro El navegador habla con el proceso de Gradio, donde vive ChatInterface y la función responder, que arma los mensajes con roles y llama por HTTP al servidor vLLM en otro proceso. vLLM aplica el chat template, corre el modelo y devuelve un stream de fragmentos; la función acumula y hace yield del texto parcial, que Gradio muestra en pantalla. navegador la persona mensaje respuesta proceso de la UI · Gradio gr.ChatInterface(fn=responder, type="messages") def responder(message, history): messages = system + history + user stream = client.chat.completions .create(..., stream=True) for chunk in stream: parcial += delta yield parcial # el total HTTP mensajes con roles stream de deltas proceso del modelo · vLLM POST /v1/chat/completions 1. aplica el chat template del modelo 2. corre el modelo (bf16, NVFP4…) 3. KV cache paginado, batching continuo 4. devuelve tokens a medida que salen se reinicia o se cambia de modelo sin tocar la UI alternativa de §2: pipeline() dentro del proceso de la UI · vale para modelos chicos; para un LLM, el modelo se sirve aparte
El patrón de §7. La función de Gradio no toca el modelo: arma la lista de mensajes con roles, hace una request HTTP y va mostrando lo que llega. El chat template, el modelo y el KV cache viven en vLLM. Cualquier otro cliente, Streamlit o un script de evaluación, habla por la misma API.

🔧 En tu proyecto

Este es el mismo esquema cliente/servidor que ya usás para servir Gemma-4 con tu vLLM parcheado: el modelo cuantizado vive en el proceso de vLLM, y cualquier cliente — Streamlit, Gradio, un script de evaluación — le habla por la misma API. La UI es intercambiable; el servidor es la inversión.

8. Errores comunes

Las demos fallan de formas repetitivas. Casi todas se ven en la consola donde corre launch(), no en el navegador.

SíntomaCausa probableQué hacer
El componente de salida queda vacío o muestra un error rojo, aunque fn corrió sin excepción en la consola.fn devuelve un tipo que el componente no entiende: un str para gr.Label cuando esperabas barras, un dict para gr.Textbox, una imagen PIL cuando gr.Image está configurado con type="numpy", o una tupla del largo equivocado con varios outputs.Revisar la tabla de §2. Probá fn a mano en Python y mirá el type() de lo que devuelve. Con varios outputs, la tupla tiene que tener exactamente tantos elementos como componentes.
Cada click tarda 10–30 segundos y la RAM/VRAM sube en cada llamada.El modelo se carga dentro de fn (pipeline(...) o from_pretrained adentro de la función), así que se recarga en cada request.Cargar el modelo una vez a nivel de módulo, fuera de fn; fn solo hace inferencia. Para LLMs, mejor todavía: servidor aparte (§7).
Un servidor de producción que "misteriosamente" es accesible desde internet por una URL *.gradio.live.share=True quedó en el launch() del código que se desplegó: abre un túnel público hacia tu proceso, saltándose el firewall, la VPN y el proxy inverso.share=True es para mandarle un link a alguien un rato. En un servidor, share=False (el default), server_name="0.0.0.0" y exponerlo solo detrás de tu proxy o VPN.
OSError: Cannot find empty port in range: 7860-7860 (o Gradio arranca en 7861 y tu proxy apunta a 7860).Hay otro proceso en el puerto — casi siempre una demo anterior que no cerraste (o un notebook con un launch() vivo).lsof -i :7860 (o ss -ltnp) para encontrarlo y matarlo; en notebooks, demo.close() antes de relanzar. Fijar server_port explícito para que falle ruidosamente en vez de correr en otro puerto.
El chat muestra palabras sueltas parpadeando, o solo la última palabra.La función yieldea el fragmento nuevo (delta) en vez del texto acumulado.Acumular en una variable y yieldear siempre el total (§7).
El chatbot "olvida" el turno anterior, o responde raro después del segundo mensaje.Se ignora history al armar los mensajes, o se está concatenando el historial como un string plano sin roles.Reconstruir la lista de mensajes con role/content desde history en cada llamada, y dejar que el servidor aplique el chat template.

9. Resumen

  1. Interface(fn, inputs, outputs) envuelve cualquier función de Python en una UI web en minutos — y el tipo que fn devuelve tiene que coincidir con lo que el componente de salida espera.
  2. Compartir es un link de una semana (share=True, un túnel hacia tu proceso) para algo rápido, o un Space permanente para publicar en serio; server_name/server_port es la forma de servir en tu propia red.
  3. gr.load("models/…") o gr.load("spaces/…") arma la demo sola a partir del Hub (la forma Interface.load era de Gradio 3) — hoy, para LLMs, mejor apuntando a un servidor de inferencia separado que cargando el modelo en el proceso de la UI.
  4. State es lo mínimo que necesitás para recordar entre llamadas; la interpretación integrada de Gradio 3 ya no existe y se arma aparte con Captum/SHAP más un componente que pinte atribuciones.
  5. Blocks es lo que necesitás para cualquier cosa más compleja que una función aislada: componentes declarados, eventos conectados a mano con .click/.submit/.then.
  6. ChatInterface + cliente openai con base_url + yield es el patrón completo para un chat con streaming sobre vLLM: la UI en un proceso, el modelo en otro.

10. Ponete a prueba

Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.

1. Tu fn devuelve "POSITIVE" y el output es gr.Label(num_top_classes=2). ¿Qué ves en pantalla y qué tendrías que devolver para ver las barras de probabilidad?

Ves la etiqueta como texto, sin barras: gr.Label acepta un str pero solo muestra confianza cuando recibe un dict etiqueta → probabilidad, como {"POSITIVE": 0.97, "NEGATIVE": 0.03}. (§2)

2. Lanzás con share=True, le mandás el link a alguien, y cerrás la laptop. ¿Cuánto dura el link?

Hasta que cerraste la laptop. El máximo es una semana, pero el link es un túnel hacia tu proceso: si el proceso muere, el link muere. Para algo que tiene que quedar disponible, es un Space. (§3)

3. ¿Por qué gr.load("models/…") no ocupa memoria en tu máquina aunque el modelo sea grande?

Porque la inferencia se hace contra la API de inferencia de Hugging Face, no localmente: tu proceso solo arma la UI y reenvía requests. Es el mismo patrón cliente/servidor de §7, con el servidor del lado de HF. (§4)

4. ¿Qué pasa si en la función de ChatInterface escribís yield delta en vez de yield parcial?

Gradio muestra lo último yieldeado, no la concatenación: ves cada fragmento reemplazando al anterior — palabras sueltas parpadeando. Hay que acumular y yieldear el total. (§7)

5. En gr.Blocks, ¿qué diferencia hay entre conectar entrada.submit(...) y boton.click(...) a la misma función?

Solo el disparador: submit se activa al apretar Enter en el textbox; click al apretar el botón. Se pueden conectar ambos a la misma fn con los mismos inputs/outputs. (§6)

6. Un compañero pone pipeline("text-generation", model=...) dentro de fn "para que quede todo junto". ¿Qué síntoma vas a ver y por qué?

Cada click tarda lo que tarda cargar el modelo, y la memoria crece con cada llamada, porque fn se ejecuta por request. El modelo se carga una vez, a nivel de módulo; fn solo infiere. (§2, §8)

11. Para practicar

✏️ Ejercicio 1 — Tipos de retorno, con y sin modelo

Sin bajar ningún modelo: armá una Interface con un "clasificador" de juguete que cuente palabras positivas y negativas, devolviendo un dict para gr.Label. Después rompelo devolviendo un str y mirá qué cambia. pip install gradio y CPU.

import gradio as gr

POS = {"bueno", "excelente", "genial", "encantó"}
NEG = {"malo", "horrible", "pérdida", "aburrido"}

def clasificar(texto):
    palabras = texto.lower().split()
    p = sum(w.strip(".,!") in POS for w in palabras)
    n = sum(w.strip(".,!") in NEG for w in palabras)
    total = p + n or 1
    return {"positivo": p / total, "negativo": n / total}   # probá cambiar esto por return "positivo"

gr.Interface(fn=clasificar, inputs=gr.Textbox(lines=2), outputs=gr.Label(num_top_classes=2),
             examples=[["Excelente, me encantó."], ["Horrible y aburrido."]]).launch(server_port=7860)
Pista / qué esperar

Con el dict, dos barras con porcentajes. Con el str, solo el texto. Después lanzalo dos veces sin cerrar el primero para ver el error de puerto ocupado de §8, y cerrá el primero con Ctrl+C.

✏️ Ejercicio 2 — Blocks con dos eventos sobre la misma función

Reescribí el ejercicio anterior con gr.Blocks, conectando tanto el botón como el Enter del textbox a la misma función, y agregando un segundo output que muestre el conteo crudo en gr.JSON.

def clasificar2(texto):
    scores = clasificar(texto)
    return scores, {"palabras": len(texto.split()), "scores": scores}

with gr.Blocks() as demo:
    with gr.Row():
        entrada = gr.Textbox(label="Reseña", lines=2)
        with gr.Column():
            etiqueta = gr.Label(num_top_classes=2)
            detalle  = gr.JSON(label="Detalle")
    boton = gr.Button("Clasificar")
    boton.click(clasificar2, inputs=entrada, outputs=[etiqueta, detalle])
    entrada.submit(clasificar2, inputs=entrada, outputs=[etiqueta, detalle])

demo.launch()
Pista / qué esperar

Dos columnas, y tanto Enter como el botón actualizan ambos outputs. Si devolvés una tupla de un solo elemento con dos outputs declarados, Gradio se queja: la longitud de la tupla tiene que coincidir con la lista de outputs.

✏️ Ejercicio 3 — Streaming sin servidor

Antes de conectar vLLM, comprobá el mecanismo de yield con un "modelo" que devuelve palabras de a una. Después reemplazá la función por la de §7 apuntando a un vLLM real si tenés uno corriendo.

import time, gradio as gr

def falso_llm(message, history):
    respuesta = f"Recibí «{message}». Llevamos {len(history)} mensajes previos en esta sesión."
    parcial = ""
    for palabra in respuesta.split():
        parcial += palabra + " "
        time.sleep(0.15)
        yield parcial          # cambiá por `yield palabra` para ver el bug de §8

gr.ChatInterface(fn=falso_llm, type="messages").launch()
Pista / qué esperar

El texto aparece palabra por palabra y el contador de mensajes previos crece de a dos por turno (usuario + asistente) — eso confirma que history llega completo. Con yield palabra ves solo la última palabra en pantalla.

12. Fuentes y para seguir