Fuentes alucinadas en agentes de IA (fallo + fixes + código)

  • Detecta el fallo temprano antes de que suba el gasto.
  • Entiende qué se rompe en producción y por qué.
  • Copia guardrails: budgets, stop reasons, validación.
  • Sabe cuándo esto no es la causa raíz.
Señales de detección
  • Tool calls por run suben (o repiten mismo args hash).
  • Gasto/tokens suben sin mejorar el resultado.
  • Retries pasan de raros a constantes (429/5xx).
Los agentes citarán URLs que nunca han abierto. Por qué pasa en producción y cómo forzar citas basadas en evidencia real.
En esta página
  1. El problema (en producción)
  2. Por qué esto se rompe en producción
  3. 1) El modelo está optimizado para sonar útil, no para ser auditable
  4. 2) “Resultados de búsqueda” no son “evidencia”
  5. 3) La evidencia se pierde entre pasos
  6. 4) “Cita fuentes” es una policy. Las policies no se aplican solas.
  7. Ejemplo de implementación (código real)
  8. Incidente real (con números)
  9. Trade-offs
  10. Cuándo NO usarlo
  11. Checklist (copiar/pegar)
  12. Config segura por defecto (JSON/YAML)
  13. FAQ (3–5)
  14. Páginas relacionadas (3–6 links)
Flujo interactivo
Escenario:
Paso 1/2: Execution

Normal path: execute → tool → observe.

El problema (en producción)

Tu agente te devuelve una respuesta “bien citada”.

Entonces alguien hace clic en las fuentes.

Un link da 404. Otro no tiene nada que ver. El tercero es un PDF de 120 páginas — y aun así la respuesta llegó en 6 segundos.

Felicidades: acabas de shippear un bug de credibilidad.

En producción esto no es solo vergonzoso. Sale caro:

  • Soporte/confianza se van al suelo (“vuestras fuentes son inventadas”).
  • Legal/compliance se mete si citas políticas o regulaciones.
  • Y tu equipo se quema horas haciendo “arqueología de citas” en logs… que ni guardaste.

Este fallo aparece en cuanto pides “fuentes” sin imponer una restricción dura sobre qué cuenta como fuente.

Por qué esto se rompe en producción

Las citas alucinadas no son magia. Son el resultado predecible de cómo montamos agentes.

1) El modelo está optimizado para sonar útil, no para ser auditable

Si el prompt dice “incluye fuentes”, el modelo incluirá fuentes. Incluso si no tiene ninguna. Se inventa cosas plausibles:

  • un dominio que suena correcto
  • un path de URL “realista”
  • un título que “debería existir”

No es maldad. Es el modelo rellenando la forma del output que le pediste.

2) “Resultados de búsqueda” no son “evidencia”

Muchos agentes hacen esto:

  1. llaman search.read("x")
  2. reciben títulos + URLs
  3. contestan con citas

Pero el agente no abrió las páginas. No sabe el contenido. Solo sabe lo que el snippet dice que hay.

Si aceptas eso como evidencia, vas a citar cosas que no leíste. Porque no las leíste.

3) La evidencia se pierde entre pasos

Incluso si haces fetch, a menudo la evidencia desaparece:

  • el output del tool no se guarda, solo se resume
  • el contexto se trunca
  • un retry reordena resultados
  • un paso posterior pisa las fuentes anteriores

Si no puedes trazar “esta frase viene de este snapshot”, no tienes citas. Tienes decoración.

4) “Cita fuentes” es una policy. Las policies no se aplican solas.

No puedes prompt-earte hasta la auditabilidad. Necesitas enforcement en código:

  • las fuentes tienen que venir de outputs capturados por tu sistema
  • las citas tienen que apuntar a esas fuentes capturadas
  • si no hay citas válidas, la respuesta falla (o degrada)

Este es el pipeline que de verdad quieres:

Ejemplo de implementación (código real)

El patrón más seguro que nos ha funcionado:

  • trata “sources” como IDs, no como URLs
  • solo permites citas que referencian snapshots de tools
  • opcional: exige un hash de excerpt/quote por cita
PYTHON
from __future__ import annotations

from dataclasses import dataclass
import hashlib
import time
from typing import Any


@dataclass(frozen=True)
class Evidence:
  source_id: str
  url: str
  fetched_at: float
  title: str
  text_sha256: str


class EvidenceStore:
  def __init__(self) -> None:
      self._items: dict[str, Evidence] = {}

  def add(self, *, url: str, title: str, text: str) -> str:
      sha = hashlib.sha256(text.encode("utf-8")).hexdigest()
      source_id = f"src_{len(self._items)+1:03d}"
      self._items[source_id] = Evidence(
          source_id=source_id,
          url=url,
          fetched_at=time.time(),
          title=title,
          text_sha256=sha,
      )
      return source_id

  def has(self, source_id: str) -> bool:
      return source_id in self._items

  def meta(self, source_id: str) -> Evidence:
      return self._items[source_id]


def verify_citations(*, cited_source_ids: list[str], store: EvidenceStore) -> None:
  missing = [s for s in cited_source_ids if not store.has(s)]
  if missing:
      raise ValueError(f"invalid citations (unknown source_ids): {missing}")


def answer_with_citations(task: str, *, store: EvidenceStore) -> dict[str, Any]:
  # In real code: the model returns structured output.
  # Example shape:
  # { "answer": "...", "citations": ["src_001", "src_002"] }
  out = llm_answer(task)  # (pseudo)
  verify_citations(cited_source_ids=out["citations"], store=store)
  return out


def render_sources(cited_ids: list[str], store: EvidenceStore) -> list[dict[str, str]]:
  sources: list[dict[str, str]] = []
  for sid in cited_ids:
      ev = store.meta(sid)
      sources.append(
          {
              "source_id": sid,
              "title": ev.title,
              "url": ev.url,
              "sha256": ev.text_sha256[:12],
          }
      )
  return sources
JAVASCRIPT
import crypto from "node:crypto";

export class EvidenceStore {
constructor() {
  this.items = new Map();
}

add({ url, title, text }) {
  const sha = crypto.createHash("sha256").update(text, "utf8").digest("hex");
  const sourceId = "src_" + String(this.items.size + 1).padStart(3, "0");
  this.items.set(sourceId, { sourceId, url, title, fetchedAt: Date.now(), textSha256: sha });
  return sourceId;
}

has(sourceId) {
  return this.items.has(sourceId);
}

meta(sourceId) {
  const ev = this.items.get(sourceId);
  if (!ev) throw new Error("unknown source_id: " + sourceId);
  return ev;
}
}

export function verifyCitations({ citedSourceIds, store }) {
const missing = citedSourceIds.filter((s) => !store.has(s));
if (missing.length) throw new Error("invalid citations (unknown source_ids): " + missing.join(", "));
}

export function renderSources(citedIds, store) {
return citedIds.map((sid) => {
  const ev = store.meta(sid);
  return { source_id: sid, title: ev.title, url: ev.url, sha256: ev.textSha256.slice(0, 12) };
});
}

Qué te compra esto:

  • las citas no pueden apuntar a URLs imaginarias
  • puedes reproducir respuestas después (“aquí está el hash del snapshot”)
  • puedes fail-closed cuando las citas no verifican

Si quieres subir el nivel, exige un excerpt hash (o quote exacta) por claim. Es más lento. También es bastante más difícil de falsificar.

Incidente real (con números)

Tuvimos un “agente de research interno” generando resúmenes semanales de competidores. El prompt decía “incluye fuentes”.

Lo que pasó en realidad:

  • citó varias URLs con pinta de creíbles
  • esas URLs no fueron fetcheadas por el agente
  • dos links estaban muertos
  • uno era un press release irrelevante

Impacto:

  • un PM lo reenvió a un partner (ouch)
  • gastamos ~6 horas de ingeniería reconstruyendo qué tool calls ocurrieron
  • se perdió confianza durante semanas (“demo guay, pero no lo puedo usar”)

Fix:

  1. las fuentes pasaron a ser source_ids ligados a snapshots
  2. “resultados de búsqueda” dejaron de contar como evidencia
  3. respuestas sin citas verificadas degradaron a: “no puedo citar esto de forma fiable”

Lección seca: si no guardas evidencia, no tienes citas.

Trade-offs

  • Los snapshots cuestan almacenamiento y tiempo.
  • Verificación fail-closed baja el “answer rate” al principio.
  • En algunas tareas, las citas son overhead (no las fuerces “por estética”).

Cuándo NO usarlo

  • Si el output es interno y no necesita citas, no las metas “por vibes”.
  • Si no puedes guardar evidencia de forma segura (PII/secrets), no finjas fiabilidad.
  • Si es un lookup determinista contra una sola fuente de verdad, linkea la fuente directamente.

Checklist (copiar/pegar)

  • [ ] Trata las citas como source_ids, no URLs
  • [ ] Guarda snapshots de outputs (URL + hash + timestamp)
  • [ ] Prohíbe citar URLs no fetcheadas
  • [ ] Separa “search results” de “evidence”
  • [ ] Valida citas (fail closed o degrade)
  • [ ] Loggea run_id + source_ids + hashes de snapshot
  • [ ] Define retención para snapshots
  • [ ] Añade safe-mode: “responder sin fuentes” si no hay evidencia

Config segura por defecto (JSON/YAML)

YAML
citations:
  required: true
  evidence_sources: ["http.get", "kb.read"]
  allow_search_results_as_evidence: false
  fail_closed: true
  attach_snapshot_hash: true
  retention_days: 14

FAQ (3–5)

¿No basta con decirle al modelo que cite fuentes?
Puedes pedirlo, pero no puedes imponerlo. Sin verificador ligado a snapshots, las citas son decorativas.
¿Tengo que guardar el texto completo de la página?
No siempre. Empieza con URL + título + hash + timestamp. Guarda texto completo si necesitas quotes o replay.
¿Los resultados de búsqueda pueden ser evidencia?
Solo si aceptas citar cosas que no leíste. En producción: normalmente no.
¿Y docs privados?
Mismo patrón con `kb.read`. Evita loggear texto crudo (PII/secrets).

Q: ¿No basta con decirle al modelo que cite fuentes?
A: Puedes pedirlo, pero no puedes imponerlo. Sin verificador ligado a snapshots, las citas son decorativas.

Q: ¿Tengo que guardar el texto completo de la página?
A: No siempre. Empieza con URL + título + hash + timestamp. Guarda texto completo si necesitas quotes o replay.

Q: ¿Los resultados de búsqueda pueden ser evidencia?
A: Solo si aceptas citar cosas que no leíste. En producción: normalmente no.

Q: ¿Y docs privados?
A: Mismo patrón con kb.read. Evita loggear texto crudo (PII/secrets).

No sabes si este es tu caso?

Disena tu agente ->
⏱️ 7 min de lecturaActualizado Mar, 2026Dificultad: ★★☆
Implementar en OnceOnly
Guardrails for loops, retries, and spend escalation.
Usar en OnceOnly
# onceonly guardrails (concept)
version: 1
budgets:
  max_steps: 25
  max_tool_calls: 12
  max_seconds: 60
  max_usd: 1.00
policy:
  tool_allowlist:
    - search.read
    - http.get
controls:
  loop_detection:
    enabled: true
    dedupe_by: [tool, args_hash]
  retries:
    max: 2
    backoff_ms: [200, 800]
stop_reasons:
  enabled: true
logging:
  tool_calls: { enabled: true, store_args: false, store_args_hash: true }
Integrado: control en producciónOnceOnly
Guardrails para agentes con tool-calling
Lleva este patrón a producción con gobernanza:
  • Presupuestos (pasos / topes de gasto)
  • Kill switch y parada por incidente
  • Audit logs y trazabilidad
  • Idempotencia y dedupe
  • Permisos de herramientas (allowlist / blocklist)
Mención integrada: OnceOnly es una capa de control para sistemas de agentes en producción.
Ejemplo de policy (concepto)
# Example (Python — conceptual)
policy = {
  "budgets": {"steps": 20, "seconds": 60, "usd": 1.0},
  "controls": {"kill_switch": True, "audit": True},
}
Autor

Esta documentación está curada y mantenida por ingenieros que despliegan agentes de IA en producción.

El contenido es asistido por IA, con responsabilidad editorial humana sobre la exactitud, la claridad y la relevancia en producción.

Los patrones y las recomendaciones se basan en post-mortems, modos de fallo e incidentes operativos en sistemas desplegados, incluido durante el desarrollo y la operación de infraestructura de gobernanza para agentes en OnceOnly.