Chatbot LLM: streaming, compresión de contexto y memoria
Chatbot paso a paso: selector de modelos, Stop real, presupuesto de contexto medido, compresión en memoria y búsqueda web vía gateway.
Nuestra selección
precios en vivo| Modelo | Veredicto | Precio |
|---|---|---|
| Gemini 3.6 Flash Rápido, con un contexto real de 1M (recuperó la aguja en 972K), y configurar el razonamiento al mínimo redujo el coste medido por llamada entre un 91-97% en tareas de un solo paso con formato de chat. | Mejor opción predeterminada | desde $1.5/M |
| DeepSeek V4 Flash La opción compatible con chat más barata de esta página, con el mayor descuento por lectura de caché de la tabla, por lo que releer un historial largo cuesta casi nada. También es el resumidor predeterminado del MVP. | Opción económica | desde $0.138/M |
| Claude Sonnet 5 La mayor consistencia de personalidad y escritura de los cuatro. Nota sobre el presupuesto: un texto idéntico se tokeniza en un 41% más de tokens que en Sonnet 4.6, así que compare el número de tokens, no los precios anunciados. | Opción de mayor calidad | desde $2/M |
| GLM-5.2 Un turno de llamada a herramienta con caché caliente costó $0.0009 frente a $0.0051 en claude-opus-4-8. La latencia mediana por turno con caché caliente fue de 6.6s, así que resérvelo para flujos en los que el usuario espera tener que esperar. | Llamadas a herramientas baratas | desde $1.4/M |
Contenido
- ¿Qué debe hacer un chatbot MVP?
- ¿Cómo está construido?
- ¿Qué modelos incluimos en el selector?
- ¿Qué ve realmente el modelo en cada turno?
- ¿Cómo funciona el prompt cache y qué modelos lo respetan?
- ¿Cómo sabes que te acercas al límite de contexto?
- ¿Qué ocurre al alcanzar el límite?
- ¿Cómo persiste la memoria entre sesiones?
- ¿Qué sucede cuando el usuario pulsa Stop?
- ¿Qué ocurre cuando falla un turno?
- ¿Cómo funciona la búsqueda web sin implementar un bucle de herramientas?
- ¿Cómo se renderiza markdown sin romperlo durante el streaming?
- ¿Cuánto cuesta una conversación?
- ¿Dónde están todos los ajustes?
- ¿Qué hemos dejado fuera y dónde se añadiría?
- Lecturas relacionadas
Esta guía recorre un chatbot completo que puedes ejecutar en local en unos dos minutos y entender en una tarde: un servidor FastAPI, una página estática, sin base de datos ni proceso de build. Explica el diseño de cada subsistema, el orden de ejecución en cada turno y los fallos que encontramos al construirlo. El código completo está en github.com/synthorai-io/use-cases, dentro de chatbot/. Todas las solicitudes pasan por un único endpoint compatible con OpenAI, así que los modelos aparecen en un desplegable y no requieren integraciones independientes.
git clone https://github.com/synthorai-io/use-cases
cd use-cases
cp .env.example .env # put your API key in it
cd chatbot
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn server:app --reload

¿Qué debe hacer un chatbot MVP?
Ocho cosas. Cada una evita un fallo concreto que aparece cuando falta:
| Función | Fallo que evita | Dónde está |
|---|---|---|
| Conversaciones persistentes (listar, buscar y renombrar) | un chat que lo olvida todo al recargar es una demo, no una herramienta | storage.py |
| Selección de modelo, incluso durante una conversación | usar un modelo dimensionado para la pregunta más difícil encarece todas las fáciles | config.py |
| Personaje editable, con presets | el system prompt es el producto; cambiarlo no debería exigir un despliegue | presets/ |
| Streaming, con un Stop que llegue al proveedor | el silencio antes del primer token parece un fallo; un Stop ficticio sigue generando costes | server.py |
| Presupuesto de contexto, con compresión al alcanzar el límite | todos los modelos tienen una ventana; agotarla en silencio hace que el bot «se vuelva tonto» | context.py |
| Memoria a largo plazo | tener que presentarte de nuevo en cada sesión es uno de los problemas más molestos de un chatbot | context.py + .data/memory.md |
| Búsqueda y recuperación web | el conocimiento del modelo quedó congelado al entrenarlo; las preguntas de chat suelen tratar temas actuales | tools.py |
| Visibilidad del coste por turno y por sesión | la factura crece con el historial; no puedes optimizar lo que no ves | server.py |
La búsqueda y la recuperación web merecen una explicación más detallada porque suelen ser las primeras funciones que se eliminan de un MVP. El conocimiento de un modelo de chat termina en la fecha de corte de su entrenamiento, meses antes del presente. Sin embargo, muchas preguntas tratan sobre información actual: precios, versiones, lanzamientos o si X ya admite Y. Responderlas con datos de entrenamiento no produce una respuesta de menor calidad, sino una respuesta incorrecta expresada con seguridad, y el usuario no sabe qué parte ha quedado obsoleta. El problema va más allá de los datos ausentes: al construir este proyecto comprobamos que un modelo sin una fecha de referencia asume que su fecha de corte es el presente. Incluso busca mal e incluye un año desactualizado en sus consultas. La recuperación no es una mejora opcional para un chatbot; marca la diferencia entre una interfaz para consultar una instantánea y un asistente.
¿Por qué dos herramientas en vez de una? Porque la búsqueda y la recuperación responden preguntas distintas. La búsqueda devuelve snippets de unos cientos de caracteres que a menudo se contradicen; responde «qué hay disponible». La recuperación obtiene una página completa y responde «qué dice esta página», que es lo necesario para verificar una cifra exacta. Una descubre y la otra comprueba. El modelo decide en cada turno si necesita alguna. Si no usa ninguna, no hay coste adicional; si las usa, los límites por uso acotan el gasto.
También incluimos el resto de funciones básicas: Regenerate, renderizado de markdown que no se rompa durante el streaming y rutas de recuperación específicas para cada fallo. Las secciones siguientes recorren la lista de arriba abajo, siguiendo aproximadamente el flujo de una solicitud por el código.
¿Cómo está construido?
Consta de tres partes: una página estática, seis pequeños archivos Python y el gateway. La página envía un mensaje; el servidor prepara el contexto, lo comprime si hace falta, devuelve la respuesta mediante server-sent events y registra el uso medido. Las conversaciones son archivos JSON que puedes inspeccionar con cat.
static/index.html the chat UI (vanilla JS: picker, budget bar, markdown, activity trail)
server.py routes; the per-turn pipeline: project → compress → stream → record
context.py token budgeting, compression call, memory file, message assembly
tools.py the /v1/messages transport used by tool-enabled turns
storage.py one JSON file per conversation under .data/ (settings included)
config.py env-driven settings: model lineup, budgets, prompts
presets/ system-prompt presets, one .txt each
Conviene entender bien el pipeline por turno de server.py. Cada mensaje del usuario pasa por los mismos cinco pasos, cada uno vinculado a una sección posterior:
user message
│
▼
[1] project the next request's size last measured prompt_tokens
│ + estimate(new message, pessimistic)
│ + completion reserve
▼
[2] over budget? ──yes──▶ compress old turns ──▶ rolling summary
│ └───────▶ durable facts ──▶ memory.md
▼
[3] assemble and send [persona][memory][summary][date][history]
│ cache mark after the system blocks
▼
[4] stream SSE back to the page delta / reasoning / search / fetch /
│ compression notice / warning / error
▼
[5] record measured usage usage.prompt_tokens becomes step [1]'s
input on the next turn
El bucle se realimenta: el valor medido en el paso 5 se convierte en la referencia del paso 1 durante el turno siguiente. Así, el presupuesto se basa siempre en lo que contó la API, no en una estimación local.
Hay una bifurcación arquitectónica importante: los turnos normales usan /v1/chat/completions; los que tienen habilitada la búsqueda o recuperación web usan /v1/messages. No es una cuestión de estilo. La sección sobre herramientas explica el comportamiento medido que obliga a separarlos.
¿Qué modelos incluimos en el selector?
El archivo .env incluye siete modelos, agrupados por nivel en el selector (rápidos y baratos, equilibrados y frontier). Desde Settings puedes añadir cualquier id disponible en el gateway; los nuevos ids se guardan en .data/models.json. La elección por defecto importa más que el catálogo: el MVP comienza con Gemini 3.6 Flash y el nivel de razonamiento al mínimo. Las respuestas de chat son tareas de un solo paso. En este tipo de trabajo, reasoning_effort: "minimal" redujo el coste medido por llamada entre un 91-97% respecto al valor predeterminado, sin diferencias de salida apreciables para un lector. El ajuste se define por modelo en la configuración, ya que no todos lo aceptan:
# config.py — extra request params per model
MODEL_PARAMS: dict[str, dict] = {
"gemini-3.6-flash": {"reasoning_effort": "minimal"},
}
DeepSeek V4 Flash aparece dos veces: como opción económica del selector y como resumidor por defecto para la compresión. Resumir también es una tarea de un solo paso que tolera cierta pérdida de calidad. Claude Sonnet 5 es la opción indicada cuando la calidad de escritura forma parte del producto. Presupuéstalo por número de tokens, no por el precio anunciado: el mismo texto genera un 41% más de tokens que en Sonnet 4.6. GLM-5.2 se gana su puesto en flujos con muchas herramientas: un turno en caliente con tool calls costó $0.0009 frente a $0.0051 con Claude Opus 4.8. Su latencia mediana en caliente fue de 6.6s, un retraso perceptible en una ventana de chat.
Las capacidades de cada modelo se miden; no se dan por supuestas. El código se adapta a sus diferencias en lugar de fingir que no existen. En este grupo, solo DeepSeek y GLM transmiten su razonamiento como reasoning_content. Los modelos Claude no devuelven bloques de razonamiento mediante este gateway ni siquiera con el parámetro correspondiente, así que la interfaz solo muestra un panel «Thinking» en directo cuando puede recibir ese contenido.
Cambiar de modelo durante una conversación no requiere cambios estructurales. El historial usa mensajes neutrales respecto al proveedor, con el formato {"role", "content"}. Una conversación puede empezar con un modelo barato y pasar al de mayor calidad cuando aparece una pregunta difícil. Sí existe un coste oculto: al cambiar se abandona el prompt cache del modelo anterior, por lo que el primer turno con el nuevo modelo relee todo el contexto a precio de caché fría.
¿Qué ve realmente el modelo en cada turno?
Un prompt por capas, construido en un único sitio y con un orden deliberado: primero el contenido más estable.
| Capa | Origen | Cuándo cambia |
|---|---|---|
| System prompt (personaje) | presets/*.txt o texto libre | nunca, salvo que se edite |
| Memoria a largo plazo | .data/memory.md | rara vez (al añadir datos) |
| Resumen acumulado | compresión | solo al comprimir |
| Fecha de referencia | reloj del servidor | cada día |
| Instrucciones de herramientas | Settings, solo en turnos con herramientas | rara vez |
| Turnos recientes | conversación | en cada turno |
La regla es colocar primero lo más estable por el funcionamiento de la caché, que veremos en la siguiente sección. El personaje no cambia, la memoria cambia pocas veces, el resumen solo se modifica al comprimir y el historial cambia en cada turno. Cada capa se sitúa detrás de las que varían con menor frecuencia.
La fecha de referencia merece un lugar en el prompt y también una posición concreta. Sin ella, el modelo asume que la fecha de corte de su entrenamiento es el presente: genera consultas con años desactualizados e interpreta una tabla de versiones como si la última fila siguiera vigente. Usamos una fecha, no un timestamp. Como forma parte del prefijo del prompt, una precisión inferior al día invalidaría la caché con cada solicitud. Además, se coloca después del personaje, la memoria y el resumen, para que esas capas conserven su caché al llegar la medianoche.
¿Cómo funciona el prompt cache y qué modelos lo respetan?
El principio es sencillo: los proveedores guardan el prefijo idéntico byte a byte de una solicitud y lo reutilizan con un gran descuento, aunque cobran un pequeño extra al escribirlo por primera vez. El chat es un caso ideal porque cada solicitud equivale a la anterior más dos mensajes; todo el historial previo constituye un prefijo estable. El recargo de escritura se recupera en el turno siguiente. En los modelos Anthropic, escribir con un TTL de 5 minutos cuesta 1.25x frente a aproximadamente 0.1x por lectura; aquí medimos la rentabilidad de la escritura. Los marcadores cache_control redujeron el coste medido un 88-89% en tres niveles de Claude. En una conversación con memoria real y un resumen, los turnos posteriores cuestan aproximadamente una décima parte del primer turno en frío.
La «caché» no es un único mecanismo. Los proveedores se dividen en dos grupos y este catálogo incluye ambos:
| Modelo | Tipo de caché | Qué haces tú | Cómo informa los aciertos |
|---|---|---|---|
| Familia Claude | explícita: puntos de corte cache_control | colocas el marcador | cache_read_input_tokens |
| DeepSeek V4 Flash | implícita: prefijo automático | nada | prompt_tokens_details.cached_tokens |
| GLM-5.2 | implícita: prefijo automático | nada | prompt_tokens_details.cached_tokens |
| Gemini 3.6 Flash | implícita; acepta el marcador pero lo ignora | nada funciona | no se informa mediante este gateway |
La caché explícita, como la de Anthropic, exige indicar el punto de corte, pero muestra exactamente qué ocurrió. Ofrece campos separados para los tokens escritos y leídos, por lo que puedes auditar el descuento en cada turno. La caché implícita, típica del ecosistema compatible con OpenAI, no necesita marcadores: se activa cuando el prefijo se repite. A cambio, el proveedor decide qué reutilizar y la única evidencia posterior es un campo cached_tokens; su fiabilidad varía mucho entre proveedores. El MVP sirve a ambos grupos: ordena el prompt de lo más estable a lo menos estable, como requiere la caché implícita, y siempre envía el marcador explícito, que el otro grupo ignora sin consecuencias. Con una sola forma de solicitud, cada modelo aprovecha la caché que admita.
La ubicación del marcador explícito surgió de las mediciones, no de la documentación. En este gateway, cache_control funciona en un bloque de sistema y se ignora en cualquier otra posición sin generar errores. El patrón habitual de marcar el mensaje más reciente del usuario, que permitiría cachear todo el historial, devuelve cero tokens cacheados al precio completo. Por eso el MVP coloca el marcador al final de la sección de sistema. El prefijo cacheable contiene el personaje, la memoria y el resumen. Esto tiene dos consecuencias. La caché solo se activa cuando el prefijo supera el mínimo del modelo, unos 1,024 tokens. Una conversación nueva no cachea nada, mientras que otra con memoria y resumen acumulados lo cachea todo. Además, editar el prefijo tiene un coste: cambiar el system prompt enfría la caché desde el primer byte, la compresión reescribe el resumen y provoca una lectura en frío que explicaremos más adelante, y cambiar de modelo abandona por completo la caché anterior.
El TTL se configura con el mismo criterio en todos los casos. La caché predeterminada de 5 minutos cubre una conversación activa; la modalidad de 1 hora cubre al usuario que se ausenta, pero duplica el recargo de escritura. El cambio se reduce a CACHE_TTL=1h. Su rentabilidad depende de si los usuarios regresan dentro de esa hora.
¿Cómo sabes que te acercas al límite de contexto?
Midiendo, no adivinando. El único recuento de tokens válido para el modelo actual es el valor usage.prompt_tokens devuelto por la API en la solicitud anterior. Los estimadores locales fallan al cambiar de proveedor y las diferencias son grandes: el mismo texto en inglés genera una diferencia del 41% entre dos modelos del mismo proveedor (lo medimos), y aún mayor entre proveedores. El MVP solo estima localmente lo que la API todavía no ha visto: el mensaje que se está enviando. Además, redondea al alza a propósito:
# context.py
def needs_compression(last_prompt_tokens, pending_text, message_count, budget=None):
if message_count <= config.KEEP_RECENT_MESSAGES:
return False # nothing old enough to fold away
projected = (
last_prompt_tokens # measured, last response
+ estimate_tokens(pending_text) # estimated, pessimistic
+ config.MAX_COMPLETION_TOKENS # worst-case reply
)
return projected > (budget or config.CONTEXT_BUDGET_TOKENS)
Sobreestimar adelanta la compresión un turno y cuesta una llamada barata de resumen. Subestimar desborda la ventana y provoca una solicitud fallida o truncada en silencio. Esta asimetría determina hacia dónde redondeamos.
«Medir» también tiene una trampa: los proveedores discrepan sobre si prompt_tokens ya incluye los tokens cacheados. Algunos informan del prompt completo; otros, solo del delta no cacheado. No es una diferencia estética porque este valor es la referencia del presupuesto. Si se resta el tamaño de la caché por error, la compresión nunca se activa y la ventana termina desbordándose en silencio. El servidor normaliza el dato: si prompt_tokens es menor que el número de tokens cacheados declarado, no puede representar el prompt completo, así que suma ambas partes. También conserva su propia estimación como mínimo del valor medido. Anatomía del uso de tokens en LLM explica qué contiene usage según el proveedor.
El presupuesto predeterminado es de 102,400 tokens, una cifra que una conversación normal no alcanzará. Es un presupuesto operativo, no el límite del modelo, y debería fijarse en el punto donde un turno empieza a costar más de lo que aporta. Para observar el mecanismo, abre Settings, reduce la ventana a unos pocos miles de tokens para una conversación —el presupuesto se define por conversación— y pega varios mensajes largos. La barra de contexto del encabezado usa la cifra medida y separa los elementos que ocupan la ventana: personaje, memoria, resumen e historial.

¿Qué ocurre al alcanzar el límite?
Comprime; no trunques. El truncado hace que el bot olvide el comienzo de la conversación, algo que el usuario percibe como si se hubiera vuelto tonto. En su lugar, todo salvo los mensajes más recientes —los últimos 8 por defecto— se integra en un resumen acumulado mediante un modelo resumidor barato. El resumen se incluye como bloque de sistema. La ventana reciente se conserva literalmente para mantener la voz a corto plazo del bot; solo se aplica compresión con pérdida al pasado más lejano. Nada desaparece en silencio: cuando se comprime, aparece un aviso en la conversación con el número de mensajes y el tamaño del resumen.
Una compresión completa:
before (projected next request 103.1k > 102.4k budget)
[persona][memory][date][ m1 ..................... m34 │ m35 ....... m42 ]
old enough to fold last 8, kept
one summary call to deepseek-v4-flash:
in: prior summary + m1 ... m34
out: {"summary": "one dense paragraph: topics, decisions,
open questions, promises",
"facts": ["prefers Python", "timezone is UTC+8"]}
after
[persona][memory + new facts][summary][date][ m35 ....... m42 ]
unchanged grows rarely replaced verbatim
La fiabilidad de la llamada de compresión depende sobre todo de tres decisiones:
- El resumen incorpora el resumen anterior. La segunda compresión integra el primer resumen junto con los nuevos mensajes antiguos. Así se mantiene un único párrafo acumulado, en lugar de una cadena de resúmenes de resúmenes que crece sin límite.
- Un fallo no cancela el turno. Si la llamada de resumen falla, el servidor elimina los turnos más antiguos sin resumir, indica al usuario exactamente qué se perdió y responde al mensaje de todos modos. Una memoria imperfecta es mejor que un chatbot fuera de servicio. Además, la ruta de error que nunca aparece en las demos es la que acaba despertándote en producción.
- El prompt es configurable, con una protección. Lo que se pide conservar al resumidor decide qué recuerda la conversación, por lo que el prompt de compresión puede editarse en cada conversación. El servidor rechaza cualquier modificación que elimine el placeholder
{transcript}: ese prompt no resumiría nada y el error aparecería mucho después, en el primer desbordamiento.COMPRESS_MAX_TOKENSsigue la misma lógica. El límite de salida deja margen respecto a lo solicitado por el prompt porque todos los turnos posteriores heredarían un resumen cortado a mitad de frase.
Desde el punto de vista de la caché, la compresión cuesta exactamente una relectura en frío. Como cambia el bloque de resumen, todo lo que sigue al bloque de memoria queda en frío durante una solicitud. Después, el nuevo prefijo más pequeño vuelve a cachearse. Ese es el coste real: una llamada de resumen más una lectura en frío permite que todos los turnos posteriores usen un contexto más pequeño y en caliente.
¿Cómo persiste la memoria entre sesiones?
El bot tiene tres almacenes de memoria, diferenciados por alcance y duración. Todas las decisiones de esta sección parten de esa división:
| Almacén | Alcance | Duración | Tamaño enviado |
|---|---|---|---|
| Turnos recientes, literales | esta conversación | hasta que la compresión los integre | texto completo de los últimos 8 mensajes |
| Resumen acumulado | esta conversación | desaparece con la conversación | un párrafo de menos de 200 palabras |
Datos de memory.md | todas las conversaciones | hasta que una persona los elimine | unas pocas líneas |
El resumen y los datos parecen similares, pero envejecen de forma distinta, por eso se guardan por separado. El resumen acumulado sigue la forma de una conversación: decisiones, preguntas abiertas y compromisos del asistente. Es correcto que desaparezca con ella. Los datos duraderos sobre el usuario, como «prefiere Python» o «su zona horaria es UTC+8», seguirán siendo válidos la semana siguiente y no deberían perderse aquí.
El prompt de compresión solicita ambas cosas a la vez y devuelve JSON: una cadena summary y un array facts. Los datos se añaden a .data/memory.md, que todas las conversaciones cargan como bloque de sistema. La extracción se realiza durante la compresión, no en un paso independiente, por razones de coste. En ese momento un modelo ya está releyendo los turnos antiguos, así que la recopilación aprovecha tokens que ya se estaban pagando. Una llamada, dos salidas.
La deduplicación del MVP compara líneas exactas, y su limitación aparece pronto: una compresión guarda «User prefers Python over Node.js» y otra posterior añade «The user prefers Python over Node.js.». La deduplicación semántica exige generar embeddings o pedir a un LLM que compare cada candidato con el almacén. Es una función real con un coste real, así que el MVP usa el método simple y deja clara la limitación. El archivo de memoria usa markdown precisamente para que una persona pueda leerlo y limpiarlo. Settings lo muestra en un campo de texto editable que también actúa como panel de transparencia: lo que el bot sabe de ti está en un archivo que puedes abrir.
¿Qué sucede cuando el usuario pulsa Stop?
El proveedor deja de generar. Esa es toda la función, y muchas interfaces de chat no la implementan: si Stop solo oculta la salida mientras la completion sigue ejecutándose, terminas pagando tokens que nadie leerá.
La cadena tiene tres eslabones. Mientras se transmite una respuesta, el botón Send se convierte en Stop en la misma posición y sin confirmación. Al pulsarlo, se aborta el fetch del navegador. El servidor comprueba si el cliente se ha desconectado entre eventos; cuando lo detecta, sale del bucle de streaming y cierra la conexión upstream para que el proveedor se detenga. El texto recibido hasta ese momento se guarda con la marca interrupted y aparece con un borde discontinuo en la conversación. La misma cadena se activa al cerrar la pestaña o perder la conexión, porque desde el servidor son el mismo evento.
La persistencia debe ejecutarse exactamente una vez y el código lo deja explícito. El guardado se realiza dentro del bucle de streaming al terminar correctamente, en el manejador de excepciones si se rompe la transmisión y en el bloque finally si hay una desconexión abrupta, cuando el framework cancela el generador y no se ejecuta nada después del bucle. La función es idempotente, así que la primera ruta que se active gana.
Stop se complementa con Regenerate: interrumpe una respuesta no deseada y vuelve a generarla sin escribir de nuevo. Regenerate elimina los turnos finales del asistente, incluido uno interrumpido, para que la conversación vuelva a terminar en el mensaje del usuario, y lo responde usando el modelo actual. Como el modelo seleccionado se aplica al turno siguiente, Stop más Regenerate también permite repetir la misma pregunta con un modelo más potente.
¿Qué ocurre cuando falla un turno?
Puede ocurrir una de seis cosas, cada una con su propia ruta de recuperación en vez de una alerta roja genérica. classify() en server.py asigna las excepciones upstream a tipos de fallo; la interfaz asigna acciones a esos tipos:
| Fallo | Qué ve el usuario | Recuperación |
|---|---|---|
| Error de red | se identifica como tal | botón Retry |
| Límite de solicitudes | tiempo de espera de retry-after, si está disponible | botón Retry |
| API key incorrecta | nombre exacto de la variable de entorno que debe corregir | editar .env |
| Filtro de contenido | «volver a intentarlo con el mismo texto también será rechazado» | Edit & resend |
| Interrupción durante el streaming | respuesta parcial conservada y marcada como interrumpida | Retry |
| Fallo de compresión | aviso que indica qué se descartó | no hace falta; el turno continúa |
Dos invariantes resuelven la mayor parte del problema. Primero, el mensaje del usuario nunca se pierde. Si la solicitud falla antes del primer evento, el servidor retira el mensaje de la conversación y la interfaz devuelve el borrador al cuadro de texto, evitando que un reintento lo envíe dos veces. Si el streaming se rompe después de recibir texto, se conserva la respuesta parcial y se marca. Segundo, una respuesta rechazada ofrece rewind, no retry. El servidor extrae el mensaje del usuario del historial y lo devuelve al cuadro de texto para editarlo, porque repetirlo literalmente contra un filtro de contenido fallará siempre de la misma forma.
Ambas reglas dependen de otra decisión de almacenamiento más sutil: nunca se guarda un turno vacío del asistente. Reenviar upstream un mensaje vacío del asistente rompe la alternancia usuario/asistente que exigen algunos proveedores. El error puede aparecer en un turno posterior y señalar el mensaje equivocado, lo que dificulta mucho el diagnóstico. El MVP elimina el contenido vacío del formato enviado y no guarda respuestas sin texto, salvo que el turno contenga actividad de búsqueda o razonamiento que merezca conservarse. En ese caso, almacena la actividad y elimina el texto vacío al enviarlo.
¿Cómo funciona la búsqueda web sin implementar un bucle de herramientas?
El gateway ejecuta las herramientas en el servidor, lo que cambia quién se encarga de cada tarea. En el function calling clásico, el bucle es tuyo: el modelo devuelve un bloque tool_calls, tu código ejecuta la herramienta, añade un mensaje tool_result y vuelve a enviar toda la conversación, una vez por llamada. Las herramientas server-side trasladan ese bucle al gateway. Declaras synthorai:web_search o synthorai:web_fetch en la solicitud y el gateway se sitúa entre el LLM y los proveedores de herramientas, pasa las llamadas del modelo a APIs externas de búsqueda y recuperación e incorpora los resultados al contexto del modelo. No hay magia: cada búsqueda y cada recuperación son llamadas a una API externa, por eso se cobran por uso. Este código no implementa ningún viaje de ida y vuelta con tool_result; solo renderiza los eventos:
browser server (tools.py) Synthorai gateway LLM / tool providers
│ POST /chat │ │
├──────────────▶│ declare tools + │
│ │ budget note │
│ ├────────────────────▶│── question + tools ──▶ [LLM]
│ │ │◀── tool_use: search ── [LLM]
│ │ │── query ─────────────▶ [search API]
│ │ search results │◀── results ─────────── [search API]
│ SSE: search ◀─┤◀────────────────────┤── results ───────────▶ [LLM]
│ │ │◀── tool_use: fetch ─── [LLM]
│ │ │── URL ───────────────▶ [fetch API]
│ │ fetch result │◀── page text ───────── [fetch API]
│ SSE: fetch ◀──┤◀────────────────────┤── page text ─────────▶ [LLM]
│ SSE: delta ◀──┤◀────────────────────┤◀── answer tokens ───── [LLM]
│ SSE: done │ │
Las responsabilidades quedan bien separadas. El modelo decide si busca, si un snippet basta o necesita recuperar una página y cuándo dejar de investigar para redactar. El gateway ejecuta las acciones: llama al proveedor de búsqueda o recuperación y devuelve el resultado al modelo. Tu servidor solo renderiza los eventos que recibe en streaming. Ambas herramientas están activadas por defecto; si un turno no necesita ninguna, no hay coste extra. Una operación aritmética sigue siendo gratuita, mientras que una pregunta del tipo «cuál es el valor actual de…» activa la búsqueda. El bucle del gateway también tiene un límite: si la búsqueda server-side se detiene al llegar a su propio máximo de iteraciones (pause_turn), el transporte reenvía el turno para reanudarlo, hasta tres veces.
Durante la implementación aprendimos cuatro cosas más valiosas que el propio happy path:
- El endpoint determina qué puedes observar. Ambos endpoints ejecutan y cobran la búsqueda —medido el 2026-08-04 tanto en canales Anthropic como Gemini—, pero solo
/v1/messagesexpone la actividad. La consulta y las URLs resultantes llegan como bloques tipados que el código puede renderizar y guardar. En/v1/chat/completions, la misma búsqueda se ejecuta de forma invisible: HTTP 200, una respuesta que empieza por «Based on the search results…» y ninguna cita ni anotación, tanto en streaming como sin él. Pagar una búsqueda que no puedes auditar produce una degradación silenciosa, el peor tipo de fallo: nada da error, simplemente desaparece la procedencia. Ese es el único motivo por el que existetools.pycomo segundo transporte, en lugar de añadir un campo a la solicitud normal. - El modelo debe conocer su presupuesto mediante texto. El gateway aplica los límites —por defecto, 3 búsquedas y 2 recuperaciones por turno— sin informar al modelo. Si el modelo los desconoce, planifica como si las herramientas fueran ilimitadas y consume el último intento a mitad del razonamiento. El turno termina con «Let me search for…» y no llega a responder. El MVP inyecta una frase con el presupuesto. Su redacción afecta al coste de forma medible: en las pruebas, no incluir ninguna nota consumió todo el presupuesto de herramientas y terminó a mitad de frase; una nota demasiado estricta hizo que el modelo se rindiera y mandara al usuario a leer la página; la redacción final omitió por completo la búsqueda, recuperó las dos páginas oficiales y fue la más barata. Puedes editarla en Settings y observar el cambio en el coste por turno.
- Los límites son el único freno. Ambas herramientas se cobran por uso y el modelo decide cuántas rondas necesita. En una prueba, un turno sin límites realizó tres búsquedas y dos recuperaciones antes de facturar un solo token de salida.
- Degrada la función, no mates el turno. Dos fallos reciben el mismo tratamiento: eliminar la herramienta, reintentar una vez e informar al usuario. Web fetch requiere que la key tenga el permiso correspondiente; si no lo tiene, toda la solicitud falla con
web_fetch_not_enableden vez de degradarse. Gemini acepta la declaración de la herramienta, pero falla al intentar usarla (Function call is missing a thought_signature). En ambos casos, perder el turno del usuario por una herramienta opcional es una mala decisión.
Toda la actividad del modelo para llegar a la respuesta —razonar, buscar y leer— se transmite en directo a un registro situado sobre la respuesta. Por defecto aparece contraído en una línea discreta («Thought for 5s · Searched the web · Read 2 pages»), pero puede desplegarse como una cronología con consultas, dominios de resultados y texto de razonamiento. El registro se guarda junto al mensaje, que es lo importante: si desaparece al recargar, no sirve para auditar una respuesta después. Las citas se muestran como etiquetas numeradas con el dominio bajo la respuesta.

¿Cómo se renderiza markdown sin romperlo durante el streaming?
Se divide el texto acumulado en un prefijo estable que se puede renderizar y una cola que todavía no. Renderizar una construcción incompleta provoca saltos en el layout cuando otro token la cierra. Por eso el renderer corta en la última línea completa. Si un bloque de código se ha abierto pero aún no tiene cierre, todo el contenido desde ese punto permanece como texto plano hasta que se cierra. La burbuja solo vuelve a renderizarse cuando crece el prefijo estable, de modo que el streaming no reconstruye el DOM por cada token. Los bloques de código incluyen un botón para copiar. El renderer son cien líneas de vanilla JS, sin librerías; esto refleja lo que necesita un MVP, no una postura contra las librerías de markdown.
¿Cuánto cuesta una conversación?
Lo que indique usage en el gateway. La responsabilidad del MVP es interpretar ese dato correctamente. Dos problemas de normalización resultaron críticos:
- Los proveedores usan nombres de campo distintos. Solo para los tokens cacheados: los modelos Anthropic informan mediante
cache_read_input_tokens; DeepSeek y GLM usan únicamenteprompt_tokens_details.cached_tokens; Gemini no devuelve ninguno. La línea de estadísticas consulta el campo disponible para que «cacheado» tenga siempre el mismo significado. - Un coste ausente no equivale a cero. El gateway incluye
costen algunos turnos y lo omite en otros, de forma reproducible cuando se declara una herramienta pero no se usa. Los turnos sin campo de coste se contabilizan y aparecen como «+N unreported» en vez de sumarse como cero. Un total que omite turnos en silencio parece la factura completa cuando en realidad solo es un mínimo.
El encabezado muestra los totales acumulados de la sesión —entrada, salida, proporción cacheada, búsquedas, recuperaciones, coste y turnos— y cada respuesta incluye su propia línea. Conviene vigilar la proporción cacheada: muestra el funcionamiento de la caché descrito antes, medido sobre tu propia conversación.
¿Dónde están todos los ajustes?
En un único panel Settings. La decisión de diseño que merece la pena copiar es su alcance: casi todo pertenece a una sola conversación.

Solo dos elementos son globales porque describen al usuario, no a una conversación: el catálogo de modelos —puedes añadir cualquier id disponible en el gateway y se guarda en .data/models.json— y el archivo de memoria a largo plazo, expuesto como un campo de texto editable. Todo lo demás pertenece a la conversación abierta: el system prompt, con los presets de presets/ en un desplegable; el presupuesto de contexto; los controles de búsqueda y recuperación web; las instrucciones de herramientas; y el prompt de compresión. Por tanto, dos conversaciones pueden ejecutarse en paralelo con personajes, presupuestos y herramientas diferentes sobre los mismos modelos. Así puedes reproducir las afirmaciones de esta guía en vez de confiar en ellas.
Cada campo tiene un pequeño indicador de información —pasa el cursor para verlo o pulsa para fijarlo— que explica el coste del cambio, porque la mayoría de estos ajustes tienen un precio que la interfaz no muestra de otro modo. Editar el personaje invalida la caché desde el primer byte y cuesta un turno en frío; cambiar las instrucciones de herramientas modifica la factura por turno; reducir el presupuesto adelanta la compresión. El desplegable de modelos de la barra superior es el único ajuste que se guarda de inmediato, ya que cambiar de modelo durante una conversación es una acción principal, no un cambio de configuración.
¿Qué hemos dejado fuera y dónde se añadiría?
Omisiones deliberadas, cada una con su punto de integración:
- Autenticación y multiusuario — una capa de sesión delante de las rutas. Las conversaciones ya tienen ids, así que asociarlas a un usuario consiste en añadir un prefijo al nombre del archivo, no en rediseñar el sistema. Mientras no exista esa capa, es una herramienta para localhost: cada solicitud consume tu API key, así que no la expongas tal cual a Internet.
- Rate limiting — en el mismo sitio y por el mismo motivo: protege un despliegue compartido, y este todavía no lo es.
- RAG — los documentos recuperados deben colocarse después del resumen y antes de los mensajes recientes. El contenido volátil va al final del prefijo para no invalidar los bloques cacheados de personaje y memoria. En cuanto al modelo, aquí es donde las opciones con 1M de contexto aprovechan sus ventanas.
- Function calling en el cliente — el bucle de streaming incorpora una rama
tool_callsy un ejecutor. El análisis de GLM-5.2 explica las diferencias de contrato entre proveedores que encontrarás. Las herramientas del gateway descritas antes evitan ese bucle a propósito. - Deduplicación semántica de memoria — genera un embedding de cada dato candidato, compáralo con el almacén y conserva los nuevos. El formato del archivo de memoria no cambia.
- Resaltado de sintaxis — el botón para copiar es la función que usa la gente; el resaltado es una decisión de librería para un frontend real.
- Una base de datos real —
storage.pycontiene unas pocas funciones. Migrarlas a SQLite lleva una tarde. Hasta que haya usuarios, los archivos JSON son precisamente la solución buscada.
Lecturas relacionadas
- Mejor LLM por caso de uso (2026): matriz de costes para chat, RAG y agentes — la fórmula de costes que optimiza este proyecto, aplicada a distintos tipos de carga.
- Gemini 3.6 Flash: el ajuste de razonamiento que multiplica el coste por 30 — las mediciones que justifican fijar el razonamiento al mínimo.
- El tokenizer de Claude Sonnet 5 — por qué la unidad correcta para comparar modelos es el número de tokens, no el precio.
- Anatomía del uso de tokens en LLM — qué contiene realmente
usagesegún el proveedor. - Tool calls con GLM 5.2 — costes de turnos en caliente y particularidades del contrato para ampliar el sistema con function calling.