Oct 2026
Un chatbot RAG gratis: router de modelos free, reintentos y un juez de decisión

Esta web tiene un asistente, el botón de estrellitas de abajo a la derecha, que responde preguntas sobre mi trabajo usando solo lo que hay en ella y en mi CV, y cita de dónde saca cada cosa. Cuesta cero euros al mes: el modelo que responde es gratuito y la función que lo llama corre en el plan gratuito de Vercel.
Lo gratis tiene trampa, claro. Los modelos gratuitos fallan más, tardan más y a veces no hacen lo que les pides. Este artículo cuenta cómo está montado y, sobre todo, los dos fallos reales que vi en producción el primer día y cómo los resolví. Lo iré actualizando con datos de uso.
El flujo completo
Las piezas son pocas:
- Las fuentes: un JSON estático que Astro genera al hacer el build.
- Una función en Go: corre en Vercel y no guarda nada.
- Dos modelos de OpenRouter: uno que redacta (
openrouter/free) y otro que decide (inception/mercury-decide:free).
Las fuentes: RAG sin base de datos vectorial
La web entera cabe en el contexto de un modelo. El build genera /ask/es.json y /ask/en.json con fuentes numeradas: la presentación, cada proyecto, la trayectoria, cómo trabajo, el CV, los casos de estudio y un extracto de cada artículo. En total son unos 15.000 tokens. No hace falta trocear ni buscar por similitud: se manda todo en cada pregunta.
// Knowledge renders the sources as the cacheable system block.
func Knowledge(sources []Source) string {
var b strings.Builder
b.WriteString("<sources>\n")
for _, s := range sources {
fmt.Fprintf(&b, "<source id=\"%d\" title=%q url=%q>\n%s\n</source>\n", s.ID, s.Title, s.URL, strings.TrimSpace(s.Text))
}
b.WriteString("</sources>")
return b.String()
}
Las instrucciones son cortas y estrictas:
- Responder solo desde las fuentes, citando cada afirmación con
[n]. - Usar de dos a cinco frases, en el idioma del visitante.
- Hablar de mí en tercera persona.
- No inventar cifras ni detalles internos de empresas.
- Rechazar en una frase lo que no va sobre mí.
La función lee las citas de la respuesta y devuelve solo las fuentes citadas, para que el navegador las pinte como enlaces.
Que las fuentes salgan del build tiene una ventaja que no esperaba: el asistente nunca está desactualizado. Si publico un artículo, la siguiente versión de la web ya lo conoce.
El modelo gratuito y sus dos fallos
openrouter/free es un router: cada petición la manda a uno de los modelos gratuitos disponibles en ese momento. Es la forma más sencilla de no pagar nada, pero significa que cada pregunta la puede responder un modelo distinto, con sus manías.
El primer día en producción vi dos fallos.
1. «El asistente no está disponible ahora mismo». Algunos modelos tardaban demasiado o devolvían un error, y la función se rendía al primer fallo.
2. El razonamiento en lugar de la respuesta. A la pregunta «¿Cómo trabaja con equipos?», un modelo devolvió esto, en inglés, aunque la web estaba en español:
The user is asking “How does Pelayo work with teams?”. I need to answer based only on the provided sources. Let me look for information about how Pelayo works with teams. Looking through the sources, I find relevant information in source 10…
Era su razonamiento, sin etiquetas, metido en el campo de la respuesta. Y además cortado, porque se había comido el límite de tokens pensando.
Primera defensa: pedir sin razonamiento y limpiar
OpenRouter tiene un parámetro para que los modelos que separan el razonamiento no lo devuelvan. Hay que dar también más margen de tokens, porque el razonamiento cuenta para max_tokens aunque no se devuelva:
payload, err := json.Marshal(map[string]any{
"model": model,
// Reasoning models spend tokens thinking before they answer; leave room for both.
"max_tokens": 1500,
// Keep the thinking out of the answer.
"reasoning": map[string]any{"exclude": true},
"messages": msgs,
})
Eso no basta con los modelos que escriben el razonamiento dentro del texto. Para esos hay dos filtros:
- Una expresión regular quita los bloques
<think>…</think>, incluso si están cortados. - Una heurística,
Leaked, busca cómo empieza un modelo a pensar en voz alta. Mira si el texto empieza por frases como «The user», «Okay,», «Let me» o «El usuario», o si en cualquier parte dice «the user is asking», «according to the rules» o «source 10». Una respuesta buena cita[10]; nunca dice «source 10».
La heurística tiene tests con el texto real que se filtró y con respuestas buenas, para no dar falsos positivos. Pero es una lista de frases, y los modelos tienen mucha imaginación.
Segunda defensa: reintentar con otro modelo
Como el router elige un modelo distinto cada vez, lo que falla con uno suele funcionar con el siguiente. La función reintenta hasta tres veces mientras quede tiempo:
for try := 1; ; try++ {
text, err = cfg.Complete(ctx, system, turns)
if err == nil {
if _, err = cfg.judge(ctx, req, text, try); err == nil {
break
}
}
if errors.Is(err, ErrQuota) || try == maxTries || ctx.Err() != nil {
return response{}, fmt.Errorf("model (try %d): %w", try, err)
}
if dl, ok := ctx.Deadline(); ok && time.Until(dl) < 6*time.Second {
return response{}, fmt.Errorf("model (try %d, out of time): %w", try, err)
}
log.Printf("ask: try %d: %v", try, err)
}
Hay tres detalles que importan:
- Cuota agotada: no se reintenta, porque no va a volver en dos segundos.
- Tiempo: la pregunta entera tiene 45 segundos, y cada llamada al modelo 20. Si quedan menos de 6, no se empieza otro intento.
- Vercel: la función necesita
maxDuration: 60envercel.json, o Vercel la corta antes.
El juez: un modelo de decisión, no otro chat
La heurística atrapa lo que ya he visto. Para lo que no he visto, uso un juez: antes de enseñar una respuesta, otro modelo decide si de verdad es una respuesta.
Podría usar otro modelo de chat con un prompt del tipo «¿esto es una respuesta? Contesta sí o no». Pero entonces tendría que analizar texto libre, y el juez podría fallar igual que el modelo al que juzga. En cambio, uso Mercury Decide (inception/mercury-decide:free), un modelo de decisión. No escribe texto: recibe un estado y preguntas tipadas, y devuelve una elección, una puntuación o un sí/no con su probabilidad, en algo menos de un segundo. En OpenRouter se llama a través de una API propia, System One (POST /api/v1/systemone), no de chat completions.
Esta es la petición que hace la función:
{
"model": "inception/mercury-decide:free",
"state": {
"visitor_question": "¿Cómo trabaja con equipos?",
"candidate_reply": "Para Pelayo, liderar es poner las cosas fáciles [10]…"
},
"questions": {
"q": {
"type": "noul",
"instructions": "Is candidate_reply a final reply to the visitor, written in Spanish, that could be shown as is?",
"criteria": {
"true": "A finished reply in Spanish addressed to the visitor: it answers the question, citing sources as [n], or says plainly that the site does not cover it, or politely declines.",
"false": "Not a reply: it thinks out loud about the question, the sources or the rules (\"The user is asking…\", \"Looking at source 10…\", \"I need to…\"), is a draft or plan, is cut off, or is not in Spanish."
}
}
}
}
noul es el tipo sí/no de System One, y los criterios describen cada lado con ejemplos. La respuesta es un número:
{ "answers": { "q": { "type": "noul", "noul": 0.93 } } }
En Go queda así:
func (cfg Config) judge(ctx context.Context, req request, text string, try int) (string, error) {
if cfg.Judge == nil {
return "", nil
}
p, err := cfg.Judge(ctx, req.Question, text, req.Lang)
if err != nil {
log.Printf("ask: judge error (try %d), answer shown unchecked: %v", try, err)
return fmt.Sprintf("judge%d=error", try), nil
}
if p < minAnswer { // 0.5
log.Printf("ask: judge rejected p=%.2f (try %d)", p, try)
return fmt.Sprintf("judge%d=%.2f rejected", try, p), fmt.Errorf("judge: not an answer (p=%.2f)", p)
}
log.Printf("ask: judge ok p=%.2f (try %d)", p, try)
return fmt.Sprintf("judge%d=%.2f", try, p), nil
}
Hay dos decisiones de diseño:
- Falla abierto: si Mercury no responde, la respuesta se muestra igual. El juez mejora la calidad, pero nunca puede tumbar el asistente.
- Un rechazo es un error más: el bucle de reintentos ya sabe qué hacer con él. No hace falta un camino aparte.
El filtro de temas, opcional
El mismo modelo sirve para otra pregunta, esta vez antes de llamar al modelo que redacta: «¿esta pregunta va sobre Pelayo o su web?». En el estado van también las preguntas anteriores, para que un «¿y eso?» de seguimiento no se tome por fuera de tema. Si la probabilidad baja de 0,15, la función contesta directamente «Solo puedo responder sobre Pelayo…» sin gastar una llamada al modelo grande. El umbral es bajo a propósito: prefiero que se cuele una pregunta rara a rechazar una buena.
Está apagado por defecto, y se enciende con la variable de entorno ASK_GATE=on. El motivo son las cuotas.
Las cuotas, en números
| Límite | Valor |
|---|---|
| Modelos gratuitos de OpenRouter, por minuto | 20 peticiones |
| Modelos gratuitos de OpenRouter, por día | 50, o 1.000 si has comprado al menos 10 $ de créditos |
| Mi límite por visitante | 8 preguntas cada 10 minutos y 40 al día |
| Mi límite por instancia de la función | 300 preguntas por hora |
| Pregunta | 500 caracteres; se mandan las 4 anteriores como contexto |
Mercury es un modelo :free, así que doy por hecho que sus llamadas cuentan en la misma cuota (lo confirmaré con los datos de uso). Sin filtro, una pregunta gasta al menos dos (respuesta y juez), y hasta seis si hay reintentos. Con filtro, una más. Con 50 al día eso da para pocas preguntas; con 1.000, sobra. Comprar 10 $ de créditos una vez, que no se gastan si solo usas modelos gratuitos, es lo que convierte esto en algo usable.
Para que nadie vacíe la cuota, la función tiene además lo mismo que el formulario de contacto:
- comprobación de origen;
- un token firmado que se pide antes de preguntar y que no vale si se usa demasiado rápido;
- límites por IP en memoria.
Cuando la cuota se agota, el visitante ve «el asistente ha agotado sus respuestas gratuitas por hoy» en lugar de un error genérico.
Cómo saber si el juez funciona
Cada veredicto queda registrado de dos maneras:
- En los logs de Vercel: líneas como
ask: judge ok p=0.94 (try 1)oask: judge rejected p=0.12 (try 1). - En la cabecera
X-Ask-Checks:judge1=0.94, ojudge1=0.12 rejected judge2=0.91si hizo falta un segundo intento. Se ve en la pestaña Red del navegador.
Lo que queda por medir
Este artículo lo escribo con el sistema recién montado. Las preguntas interesantes las responderán los datos de unos días en producción:
- Cuántas respuestas rechaza Mercury.
- Cuántas de esas habría dejado pasar la heurística.
- Cuántas veces hace falta un tercer intento.
- Si el umbral de 0,5 está bien puesto.
Cuando los tenga, los añadiré aquí.
La idea de fondo me parece más general que este asistente. Un modelo barato y poco fiable, más un modelo de decisión rápido que lo vigila, más reintentos, da un sistema bastante más fiable que cualquiera de sus partes. Y el juez no necesita ser listo: solo tiene que saber distinguir una respuesta de algo que no lo es.