Un usuario le hizo a nuestro copiloto de búsqueda documental una pregunta completamente razonable: cuántos documentos había creado una persona en particular. El copiloto respondió con un número. Con confianza. Con una frase bien redactada alrededor.

La respuesta real, calculada por la base de datos, era 84. El copiloto dijo otra cosa — porque hizo lo que todo sistema RAG hace cuando recibe una pregunta de agregación: contó lo que podía ver.

Esta es la afirmación que quiero defender, porque creo que muchos de nosotros estamos desplegando este bug ahora mismo:

💡 Un LLM nunca debería calcular un agregado. Ni conteos, ni sumas, ni “el más reciente”. Si la pregunta es aritmética sobre el corpus, la respuesta viene del sistema de registro — el único trabajo del modelo es redactarla.

🔢 Por qué el conteo del modelo estaba condenado desde el inicio

Este es un copiloto de búsqueda documental multi-agente para un producto de gestión de calidad, construido con LangGraph y Bedrock. Cuando llega una pregunta de metadatos (“documentos creados por X”), un agente de búsqueda consulta al servicio de documentos por las filas que coinciden, las reordena y filtra por permisos de vista, colapsa múltiples versiones del mismo documento en una sola tarjeta, y muestra como máximo 30 resultados.

Cada uno de esos pasos es correcto para la búsqueda. Cada uno de ellos destruye el conteo:

EtapaQué haceQué significa “cuántos” aquí
Servicio de documentos (SQL)aplica el filtro contra la base de datostotal real: 84
Recuperación (retrieval)devuelve las filas top-klimitado
Filtro de permisos de vistadescarta filas que este usuario no puede vermenos
Colapso de versiones5 versiones de un doc → 1 tarjetatodavía menos
Conjunto mostradocomo máximo 30 tarjetas≤ 30, siempre

El modelo está al fondo de ese embudo. Cuando “cuenta”, cuenta la última fila de la tabla. El usuario preguntó por la primera fila. Todo lo que está en medio es higiene de recuperación que convirtió silenciosamente una pregunta de base de datos en una pregunta de ventana de contexto.

Y esto es lo que lo hace peor que un crash: la respuesta es plausible. Un conteo limitado a 30 y sin duplicados parece un número real. Nadie entrecierra los ojos ante “tienes 27 documentos” como los entrecerraría ante un stack trace. En un contexto de gestión de calidad, alguien podría poner ese número en un reporte.

🪤 El giro: teníamos el número correcto todo el tiempo — y lo sobrescribimos

Esta es la parte que duele. El servicio de documentos devuelve el total real con cada consulta filtrada. El nodo de búsqueda incluso lo guardaba en el estado del agente, en un campo llamado total_available_results.

Luego, unos nodos más abajo, el nodo de filtro de permisos sobrescribía ese mismo campo con el conteo mostrado — deliberadamente, y por una buena razón:

return {
    "sources": diversified,
    # Honest "documents found": distinct logical documents after the view gate and
    # version-collapse, so the headline count matches the cards (5 versions → 4 docs),
    # not the service's version-row total.
    "total_available_results": len(diversified_all),
    ...
}

Ese comentario es correcto. Si la interfaz dice “se encontraron 12” y muestra 12 tarjetas, los números deben coincidir. El bug no fue ninguna de las dos escrituras por separado — fue un solo campo de estado cargando dos significados diferentes en dos puntos distintos del pipeline. Aguas arriba significaba “coincidencias en la base de datos”; aguas abajo significaba “tarjetas en pantalla”. Quien lo leyera al final obtenía el significado más fresco.

💡 Un campo de estado cuyo significado muta a mitad del pipeline es una fábrica de bugs. Si un valor es autoritativo, dale su propio campo y no dejes que nada aguas abajo lo toque.

🔧 El arreglo: la base de datos cuenta, el modelo narra

Entonces hice exactamente eso — preservé el total real del servicio en su propio campo de estado, lo llevé hasta los dos lugares que responden al usuario, y dejé el conteo mostrado intacto para la lista de tarjetas y el paginador.

Primero, el estado recibe un campo intocable:

class DocumentsSearchState(OrchestratorState, total=False):
    ...
    total_available_results: int   # surfaced: view-gated, version-collapsed, capped at 30

    # The documents service's true match total for the filter, before the 30-cap and
    # version-collapse. Authoritative for a "how many" count; 0 on a content-only
    # search, which has no exact total.
    total_matching_records: int

Luego, el nodo de resultado — el que convierte el estado de búsqueda tanto en la línea de estado visible al usuario como en el mensaje sobre el que el agente razona — usa el número autoritativo en ambos:

total_matching = state.get("total_matching_records") or 0
headline_total = total_matching if total_matching > total else total

found_msg = (
    f"Showing {len(sources)} of {headline_total} matching documents"
    if headline_total > len(sources)
    else f"Found {len(sources)} matching documents"
)
...
count_note = (
    f" {total_matching} documents match in total "
    "(authoritative count; use this for a count question)."
    if total_matching > len(sources)
    else ""
)
return f"Returned {len(sources)} document cards to the user.{count_note} ..."

Y el prompt del agente cierra el ciclo con una instrucción explícita:

For a "how many" question, answer with the authoritative total the tool
reports ("N documents match in total"), never the number of cards shown,
which is capped.

Fíjate en la estructura de doble seguridad, porque importa: el número entra en el contexto del agente (para que el modelo pueda redactar la respuesta alrededor de él) y en el titular visible al usuario (“Mostrando 5 de 84 documentos que coinciden”). Incluso en un turno donde el modelo ignora la nota e inventa un conteo, la interfaz muestra el total real justo al lado. El prompt lo pide; el titular no tiene que pedirlo.

Tres tests pequeños fijan el comportamiento: la nota de conteo aparece cuando hay más coincidencias que tarjetas, desaparece cuando se muestra todo, y nunca aparece en una búsqueda semántica solo de contenido — porque un ranking por similitud no tiene un conjunto nítido de “coincidencias”, así que fingir que tiene un total exacto sería la misma mentira con disfraz nuevo.

📅 El bug hermano: ¿“los últimos 10 días” de qué?

Mismo release, misma causa raíz con otro sombrero. Los usuarios pedían “documentos creados en los últimos 10 días” y el copiloto tenía que convertir eso en un filtro de fechas — sin saber qué día es hoy. Un modelo no sabe la fecha actual. Con gusto adivinará una desde sus datos de entrenamiento, lo cual es exactamente tan incorrecto como contar su ventana de contexto.

El arreglo es un middleware diminuto de LangGraph que inyecta la fecha actual en el mensaje de sistema en cada llamada al modelo:

def current_date_context(now: datetime | None = None) -> str:
    day = (now or datetime.now(UTC)).date()
    return (
        f"Today's date is {day.isoformat()} (UTC). Resolve any relative date "
        '("today", "yesterday", "the last 10 days", "since March") '
        "to concrete ISO-8601 dates relative to it before building a date filter."
    )

class CurrentDateMiddleware(AgentMiddleware):
    async def awrap_model_call(self, request, handler):
        base = request.system_message
        line = current_date_context()
        merged = line if base is None else f"{base.content}\n\n{line}"
        return await handler(request.override(system_message=SystemMessage(content=merged)))

Un detalle nada obvio: se inyecta por llamada y nunca se agrega a la conversación persistida. Escribe “hoy es 2026-07-30” en el historial del hilo y una conversación retomada la semana siguiente cargará una fecha obsoleta en la que el modelo confiará por completo.

Ambos arreglos son el mismo principio: el modelo no sabe nada que tú no le entregues, y aun así responderá. Conteos, fechas, totales — cualquier cosa que sea un dato sobre tu sistema y no sobre el lenguaje tiene que llegar como contexto, calculado por algo que no puede alucinar.

🤔 Dónde me cuestionaría a mí mismo

Límites honestos de lo que se desplegó:

  • Nada obliga al modelo a usar el número. El prompt instruye, el mensaje de la herramienta provee, el titular respalda — pero no hay un chequeo de salida que verifique que el conteo en la prosa del modelo sea igual al autoritativo. Esa es una evaluación (eval) que me gustaría tener y todavía no tengo.
  • Las búsquedas solo de contenido siguen sin conteo real. Un ranking semántico sobre fragmentos realmente no tiene un total exacto de coincidencias, así que esos turnos caen de vuelta al conteo mostrado. Podría argumentarse que lo honesto ahí es negarse a dar un número. No llegué tan lejos.
  • Esto solo cubre conteos. Sumas, promedios, “qué autor tiene más” — cualquiera de esos necesitaría endpoints de agregación reales del lado del servicio. El patrón se extiende; la implementación todavía no.

Y la decisión de diseño que no dejo de dar vueltas: le entregué al modelo el número en línea, como prosa dentro del resultado de la herramienta. La alternativa purista-agentica es una herramienta dedicada count_documents que el modelo invoca cuando detecta una pregunta de agregación — separación más limpia, un round trip más, y un nuevo modo de fallo donde el modelo no la invoca. Me quedé con lo inline porque la búsqueda ya pagaba por el conteo; una llamada a herramienta se sentía como latencia por ideología.

Entonces: ¿dónde trazas esa línea — le das el número al modelo, o le das una herramienta para que lo obtenga? Si tu copiloto responde preguntas de “cuántos” hoy, me encantaría saber de qué lado estás y si te ha funcionado.


Gracias por llegar hasta el final 🙏 Si tu asistente alguna vez anunció un total que te hizo pensar “espera, eso no puede estar bien”, me encantaría intercambiar notas sobre cómo lo arreglaste — búscame en LinkedIn.