Галюциновані джерела в AI-агентах (failure mode + fixes + код)

  • Побач ранні сигнали, поки рахунок не поліз вгору.
  • Зрозумій, що ламається в проді й чому.
  • Скопіюй guardrails: budgets, stop reasons, validation.
  • Знай, коли це не справжня root cause.
Сигнали виявлення
  • Tool calls на run зростають (або повторюються з args hash).
  • Витрати/токени ростуть без кращих результатів.
  • Retries стають постійними (429/5xx).
Агенти впевнено цитують URL, які ніколи не фетчили. Чому це стається в проді й як змусити цитати спиратись на реальну evidence.
На цій сторінці
  1. Проблема (з реального продакшену)
  2. Чому це ламається в продакшені
  3. 1) Модель оптимізована “виглядати корисною”, а не бути аудиторською
  4. 2) “Результати пошуку” ≠ “доказ”
  5. 3) Evidence губиться між кроками
  6. 4) “Цитуй джерела” — це policy. Policy не виконується сама
  7. Приклад реалізації (реальний код)
  8. Реальний інцидент (з цифрами)
  9. Компроміси
  10. Коли НЕ варто
  11. Чекліст (можна копіювати)
  12. Безпечний дефолтний конфіг (JSON/YAML)
  13. FAQ (3–5)
  14. Пов’язані сторінки (3–6 лінків)
Інтерактивний флоу
Сценарій:
Крок 1/2: Execution

Normal path: execute → tool → observe.

Проблема (з реального продакшену)

Твій агент видає відповідь “з нормальними джерелами”.

Потім хтось клікає “sources”.

Один лінк — 404. Другий — взагалі не про те. Третій — PDF на 120 сторінок, який агент “прочитав” за 6 секунд.

Вітаю: ти зашипив баг довіри.

У проді це не просто соромно. Це дорого:

  • саппорт і довіра горять (“ви вигадали джерела”)
  • legal/compliance приходить, якщо ти цитуєш політики/регуляції
  • команда витрачає години на “археологію цитат” у логах… яких ти не зберіг

Цей фейл з’являється в той момент, коли ти просиш “sources”, але не задаєш жорстке правило, що вважається джерелом.

Чому це ламається в продакшені

Галюциновані цитати — не магія. Це передбачуваний результат того, як ми будуємо агентів.

1) Модель оптимізована “виглядати корисною”, а не бути аудиторською

Якщо промпт каже “додай джерела”, модель додасть джерела. Навіть якщо їх немає. Вона вигадає щось правдоподібне:

  • домен, який звучить “правильно”
  • URL path, який виглядає реально
  • назву документу, яка “мала б існувати”

Це не “брехня з умислом”. Це заповнення форми відповіді, яку ти попросив.

2) “Результати пошуку” ≠ “доказ”

Багато агентів роблять так:

  1. search.read("x")
  2. отримують тайтли + URLs
  3. відповідають із цитатами

Але агент не відкривав сторінки. Він не знає контент. Він знає тільки те, що snippet обіцяє.

Якщо ти приймаєш це як evidence — ти цитуєш те, чого не читав. Бо не читав.

3) Evidence губиться між кроками

Навіть якщо ти фетчиш сторінки, evidence часто “випадає”:

  • tool output не зберігається, лише сумаризується
  • контекст тримається на чесному слові й тріскається від truncation
  • retry змінює порядок результатів
  • пізніший крок перезаписує ранні джерела

Якщо ти не можеш показати “цей абзац з цього snapshot”, у тебе не citations. У тебе декор.

4) “Цитуй джерела” — це policy. Policy не виконується сама

Промптом ти не зробиш аудит. Потрібен enforcement у коді:

  • sources мають приходити з tool outputs, які система захопила
  • citations мають посилатися на ці захоплені sources
  • output без валідних citations має фейлитись (або деградувати)

Пайплайн, який реально працює:

Приклад реалізації (реальний код)

Найбезпечніший патерн, який ми бачили:

  • “sources” — це IDs, а не URLs
  • citations дозволені лише на snapshotted tool outputs
  • опційно: вимагай hash на короткий excerpt/quote для кожної цитати
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) };
});
}

Що це дає:

  • citations не можуть вказувати на “уявні” URLs
  • можна відтворювати відповіді (snapshot hash)
  • можна fail-closed, якщо citations не валідуються

Якщо хочеш жорсткіше — вимагай excerpt hash (або точну цитату) для кожного твердження. Повільніше. Зате підробляти складніше.

Реальний інцидент (з цифрами)

У нас був “внутрішній research-агент”, який робив тижневі конкурентні саммарі. Йому сказали “include sources”.

Що сталося:

  • він цитував кілька “солідних” URLs
  • ці URLs не були фетчені агентом
  • два лінки були мертві
  • один — взагалі інший пресреліз

Impact:

  • PM переслав документ партнеру (ой)
  • ми витратили ~6 інженер-годин відновлюючи, які tool calls були
  • місяць була недовіра (“гарна демка, але я не можу це юзати”)

Fix:

  1. джерела стали source_id, прив’язаними до snapshots
  2. “search results” перестали бути evidence
  3. без верифікованих цитат агент деградує до: “не можу надійно процитувати”

Суха мораль: якщо не зберіг evidence — у тебе немає citations.

Компроміси

  • Evidence snapshots коштують storage і час.
  • Fail-closed зменшує “answer rate” на старті.
  • Для деяких задач citations — зайвий overhead. Не треба насилувати це всюди.

Коли НЕ варто

  • Якщо відповідь внутрішня і не потребує цитат — не додавай їх “для галочки”.
  • Якщо ти не можеш безпечно фетчити/зберігати evidence (PII/secrets) — не роби вигляд, що citations надійні.
  • Якщо це детермінований lookup з одного source of truth — просто дай лінк на джерело.

Чекліст (можна копіювати)

  • [ ] Цитати = source_id, не URLs
  • [ ] Зберігай snapshots tool outputs (URL + hash + timestamp)
  • [ ] Заборони citations на unfetched URLs
  • [ ] Відокреми “search results” від “evidence”
  • [ ] Валідуй citations (fail closed або degrade)
  • [ ] Логуй run_id + source_id + snapshot hashes
  • [ ] Додай retention policy для snapshots
  • [ ] Safe-mode: “відповідь без джерел”, якщо evidence недоступний

Безпечний дефолтний конфіг (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)

Можна просто попросити модель додати джерела?
Можна. Але це не enforcement. Без верифікатора, прив’язаного до snapshots, citations — декорація.
Треба зберігати весь текст сторінки?
Не завжди. Почни з URL + title + hash + timestamp. Додавай повний текст, якщо потрібні quotes або replay.
Search results можуть бути evidence?
Лише якщо ти ок цитувати те, що не читав. У проді: зазвичай ні.
А що з приватними доками?
Той самий патерн через `kb.read`. Не лий сирий текст у логи (PII/secrets).

Q: Можна просто попросити модель додати джерела?
A: Можна. Але це не enforcement. Без верифікатора, прив’язаного до snapshots, citations — декорація.

Q: Треба зберігати весь текст сторінки?
A: Не завжди. Почни з URL + title + hash + timestamp. Додавай повний текст, якщо потрібні quotes або replay.

Q: Search results можуть бути evidence?
A: Лише якщо ти ок цитувати те, що не читав. У проді: зазвичай ні.

Q: А що з приватними доками?
A: Той самий патерн через kb.read. Не лий сирий текст у логи (PII/secrets).

Пов’язані сторінки (3–6 лінків)

Не впевнені, що це ваш кейс?

Спроєктувати агента →
⏱️ 7 хв читанняОновлено Бер, 2026Складність: ★★☆
Реалізувати в OnceOnly
Guardrails for loops, retries, and spend escalation.
Використати в 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 }
Інтегровано: продакшен-контрольOnceOnly
Додай guardrails до агентів з tool-calling
Зашип цей патерн з governance:
  • Бюджетами (кроки / ліміти витрат)
  • Kill switch та аварійна зупинка
  • Audit logs та трасування
  • Ідемпотентність і dedupe
  • Дозволами на інструменти (allowlist / blocklist)
Інтегрована згадка: OnceOnly — контрольний шар для продакшен агент-систем.
Приклад policy (концепт)
# Example (Python — conceptual)
policy = {
  "budgets": {"steps": 20, "seconds": 60, "usd": 1.0},
  "controls": {"kill_switch": True, "audit": True},
}
Автор

Цю документацію курують і підтримують інженери, які запускають AI-агентів у продакшені.

Контент створено з допомогою AI, із людською редакторською відповідальністю за точність, ясність і продакшн-релевантність.

Патерни та рекомендації базуються на постмортемах, режимах відмов і операційних інцидентах у розгорнутих системах, зокрема під час розробки та експлуатації governance-інфраструктури для агентів у OnceOnly.