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:
- llaman
search.read("x") - reciben títulos + URLs
- 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
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 sourcesimport 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:
- las fuentes pasaron a ser
source_ids ligados a snapshots - “resultados de búsqueda” dejaron de contar como evidencia
- 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)
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)
Usado por patrones
Fallos relacionados
- AI Agent Infinite Loop (Detectar + arreglar, con código)
- Explosión de presupuesto (cuando un agente quema dinero) + fixes + código
- Tool Spam Loops (fallo del agente + fixes + código)
- Incidentes de exceso de tokens (prompt bloat) + fixes + código
- Corrupción de respuestas de tools (schema drift + truncation) + código
Gobernanza requerida
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).
Páginas relacionadas (3–6 links)
- Foundations: Cómo usan tools los agentes · Cómo afectan los límites del LLM a los agentes
- Failure: Prompt injection · Infinite loop
- Governance: Tool permissions (allowlists)
- Production stack: Production agent stack