Sources hallucinées par un agent (failure mode + fixes + code)

  • Repère la panne tôt, avant que la facture grimpe.
  • Comprends ce qui casse en prod, et pourquoi.
  • Copie des garde-fous : budgets, stop reasons, validation.
  • Sache quand ce n’est pas la vraie cause.
Signaux de détection
  • Tool calls/run explosent (ou se répètent avec args hash).
  • Spend/tokens montent sans amélioration des outputs.
  • Retries passent de rares à constants (429/5xx).
Les agents citent des URLs qu’ils n’ont jamais ouvertes. Voilà pourquoi ça arrive en prod et comment forcer des citations basées sur de vraies preuves.
Sur cette page
  1. Le problème (côté prod)
  2. Pourquoi ça casse en prod
  3. 1) Le modèle est optimisé pour avoir l’air utile, pas pour être auditable
  4. 2) “Résultats de recherche” ≠ preuve
  5. 3) La preuve se perd entre les étapes
  6. 4) “Citer des sources” est une policy. Une policy ne s’enforce pas toute seule.
  7. Exemple d’implémentation (code réel)
  8. Incident réel (avec chiffres)
  9. Compromis
  10. Quand NE PAS l’utiliser
  11. Checklist (copier-coller)
  12. Config par défaut sûre (JSON/YAML)
  13. FAQ (3–5)
  14. Pages liées (3–6 liens)
Flux interactif
Scénario:
Étape 1/2: Execution

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 :

  1. search.read("x")
  2. reçoivent titres + URLs
  3. 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
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]:
  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) };
});
}

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 :

  1. sources = source_id liées à des snapshots
  2. search results ≠ evidence
  3. 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)

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)

Je peux juste demander au modèle de citer des sources ?
Tu peux, mais tu ne peux pas l’enforcer. Sans vérif liée aux snapshots, les citations sont décoratives.
Dois-je stocker tout le texte ?
Pas forcément. Commence avec URL + title + hash + timestamp. Ajoute le full text si tu veux des quotes ou du replay.
Les résultats de recherche peuvent compter comme preuve ?
Si tu acceptes de citer des choses non lues. En prod : rarement une bonne idée.
Et pour des docs privés ?
Même pattern via `kb.read`. Évite de loguer du texte brut (PII/secrets).

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)

Pas sur que ce soit votre cas ?

Concevez votre agent ->
⏱️ 7 min de lectureMis à jour Mars, 2026Difficulté: ★★☆
Implémenter dans OnceOnly
Guardrails for loops, retries, and spend escalation.
Utiliser dans 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 }
Intégré : contrôle en productionOnceOnly
Ajoutez des garde-fous aux agents tool-calling
Livrez ce pattern avec de la gouvernance :
  • Budgets (steps / plafonds de coût)
  • Kill switch & arrêt incident
  • Audit logs & traçabilité
  • Idempotence & déduplication
  • Permissions outils (allowlist / blocklist)
Mention intégrée : OnceOnly est une couche de contrôle pour des systèmes d’agents en prod.
Exemple de policy (concept)
# Example (Python — conceptual)
policy = {
  "budgets": {"steps": 20, "seconds": 60, "usd": 1.0},
  "controls": {"kill_switch": True, "audit": True},
}
Auteur

Cette documentation est organisée et maintenue par des ingénieurs qui déploient des agents IA en production.

Le contenu est assisté par l’IA, avec une responsabilité éditoriale humaine quant à l’exactitude, la clarté et la pertinence en production.

Les patterns et recommandations s’appuient sur des post-mortems, des modes de défaillance et des incidents opérationnels dans des systèmes déployés, notamment lors du développement et de l’exploitation d’une infrastructure de gouvernance pour les agents chez OnceOnly.