Normal path: execute → tool → observe.
Le problème (côté prod)
Ton agent te sort une réponse “bien sourcée”.
Puis quelqu’un clique les sources.
Un lien renvoie 404. Un autre n’a rien à voir. Un troisième est un PDF de 120 pages — et pourtant la réponse est arrivée en 6 secondes.
Bravo : tu as shipé un bug de crédibilité.
En prod, ce n’est pas juste gênant. C’est coûteux :
- Support / confiance prennent cher (“vos sources sont bidon”).
- Legal/compliance débarque si tu cites des policies ou des regs.
- Et ton équipe perd des heures en “archéologie des citations” dans des logs… qui n’existent pas.
Ce failure mode apparaît dès que tu demandes au modèle des “sources” sans contrainte dure sur ce qui compte comme source.
Pourquoi ça casse en prod
Les citations hallucinées, ce n’est pas de la magie. C’est un résultat prévisible.
1) Le modèle est optimisé pour avoir l’air utile, pas pour être auditable
Si le prompt dit “include sources”, il va inclure des sources. Même s’il n’en a pas. Il invente des trucs plausibles :
- un domaine qui “sonne vrai”
- un chemin d’URL qui a l’air réel
- un titre qui “devrait exister”
Ce n’est pas un mensonge intentionnel. C’est le modèle qui remplit la forme demandée.
2) “Résultats de recherche” ≠ preuve
Beaucoup d’agents font :
search.read("x")- reçoivent titres + URLs
- répondent avec citations
Sauf que l’agent n’a pas fetch les pages. Il ne connaît pas le contenu. Il ne connaît que ce que le snippet prétend.
Si tu acceptes ça comme preuve, tu cites des trucs que tu n’as pas lus. Parce que tu ne les as pas lus.
3) La preuve se perd entre les étapes
Même si tu fetch :
- le tool output n’est pas stocké, juste résumé
- le contexte se fait tronquer
- un retry réordonne les résultats
- une étape écrase les sources précédentes
Si tu ne peux pas tracer “cette phrase vient de ce snapshot”, tu n’as pas des citations. Tu as de la déco.
4) “Citer des sources” est une policy. Une policy ne s’enforce pas toute seule.
Tu ne peux pas “prompt” ton chemin vers l’auditabilité. Il faut de l’enforcement en code :
- les sources doivent venir de tool outputs snapshottés
- les citations doivent référencer ces snapshots
- si ça ne vérifie pas, on fail-closed (ou on dégrade)
Pipeline cible :
Exemple d’implémentation (code réel)
Le pattern le plus safe qu’on ait trouvé :
- les citations sont des IDs, pas des URLs
- on n’accepte que des citations vers des tool snapshots
- optionnel : hash d’extrait / quote par citation
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]:
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) };
});
}Ce que ça t’apporte :
- impossible de citer des URLs imaginaires
- replay/debug possible via hash de snapshot
- fail-closed quand les citations ne vérifient pas
Incident réel (avec chiffres)
On avait un agent “research interne” qui générait un résumé compétitif hebdo. On lui demandait “d’inclure des sources”.
Ce qui s’est passé :
- il a cité des URLs crédibles
- il ne les avait pas fetch
- 2 liens étaient morts
- 1 lien était une PR hors-sujet
Impact :
- un PM a forwardé le doc à un partenaire (ouch)
- ~6 heures d’ingénierie pour reconstituer “qu’est-ce qui a été lu ?”
- confiance perdue (“démo cool, inutilisable”)
Fix :
- sources =
source_idliées à des snapshots - search results ≠ evidence
- si citations invalides : réponse dégradée (“je ne peux pas citer de façon fiable”)
Leçon sèche : sans preuve stockée, tu n’as pas de citations.
Compromis
- Snapshots = stockage + latence.
- Fail-closed baisse le taux de réponses au début.
- Certaines pages n’ont pas besoin de citations : ne force pas partout.
Quand NE PAS l’utiliser
- Si l’output est interne et sans enjeu, ne rajoute pas des citations “pour faire sérieux”.
- Si tu ne peux pas fetch/stock de la preuve proprement (PII/secrets), ne fais pas semblant.
- Si tu fais des lookups déterministes, link directement la source of truth.
Checklist (copier-coller)
- [ ] Citations =
source_id, pas URLs - [ ] Stocker snapshots (URL + hash + timestamp)
- [ ] Interdire citations vers URLs non fetch
- [ ] Séparer search results et evidence
- [ ] Vérifier citations (fail-closed ou dégrader)
- [ ] Logs:
run_id+source_id+ hashes - [ ] Politique de rétention
- [ ] Safe-mode si evidence indisponible
Config par défaut sûre (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)
Utilisé par les patterns
Pannes associées
Gouvernance requise
Q : Je peux juste demander au modèle de citer des sources ?
R : Tu peux, mais tu ne peux pas l’enforcer. Sans vérif liée aux snapshots, les citations sont décoratives.
Q : Dois-je stocker tout le texte ?
R : Pas forcément. Commence avec URL + title + hash + timestamp. Ajoute le full text si tu veux des quotes ou du replay.
Q : Les résultats de recherche peuvent compter comme preuve ?
R : Si tu acceptes de citer des choses non lues. En prod : rarement une bonne idée.
Q : Et pour des docs privés ?
R : Même pattern via kb.read. Évite de loguer du texte brut (PII/secrets).
Pages liées (3–6 liens)
- Foundations: Comment les agents utilisent des tools · Limites des LLM et agents
- Failure: Prompt injection · Boucle infinie
- Governance: Tool permissions
- Production stack: Production stack