Normal path: execute → tool → observe.
Проблема (з реального продакшену)
Твій агент видає відповідь “з нормальними джерелами”.
Потім хтось клікає “sources”.
Один лінк — 404. Другий — взагалі не про те. Третій — PDF на 120 сторінок, який агент “прочитав” за 6 секунд.
Вітаю: ти зашипив баг довіри.
У проді це не просто соромно. Це дорого:
- саппорт і довіра горять (“ви вигадали джерела”)
- legal/compliance приходить, якщо ти цитуєш політики/регуляції
- команда витрачає години на “археологію цитат” у логах… яких ти не зберіг
Цей фейл з’являється в той момент, коли ти просиш “sources”, але не задаєш жорстке правило, що вважається джерелом.
Чому це ламається в продакшені
Галюциновані цитати — не магія. Це передбачуваний результат того, як ми будуємо агентів.
1) Модель оптимізована “виглядати корисною”, а не бути аудиторською
Якщо промпт каже “додай джерела”, модель додасть джерела. Навіть якщо їх немає. Вона вигадає щось правдоподібне:
- домен, який звучить “правильно”
- URL path, який виглядає реально
- назву документу, яка “мала б існувати”
Це не “брехня з умислом”. Це заповнення форми відповіді, яку ти попросив.
2) “Результати пошуку” ≠ “доказ”
Багато агентів роблять так:
search.read("x")- отримують тайтли + URLs
- відповідають із цитатами
Але агент не відкривав сторінки. Він не знає контент. Він знає тільки те, що 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 для кожної цитати
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) };
});
}Що це дає:
- citations не можуть вказувати на “уявні” URLs
- можна відтворювати відповіді (snapshot hash)
- можна fail-closed, якщо citations не валідуються
Якщо хочеш жорсткіше — вимагай excerpt hash (або точну цитату) для кожного твердження. Повільніше. Зате підробляти складніше.
Реальний інцидент (з цифрами)
У нас був “внутрішній research-агент”, який робив тижневі конкурентні саммарі. Йому сказали “include sources”.
Що сталося:
- він цитував кілька “солідних” URLs
- ці URLs не були фетчені агентом
- два лінки були мертві
- один — взагалі інший пресреліз
Impact:
- PM переслав документ партнеру (ой)
- ми витратили ~6 інженер-годин відновлюючи, які tool calls були
- місяць була недовіра (“гарна демка, але я не можу це юзати”)
Fix:
- джерела стали
source_id, прив’язаними до snapshots - “search results” перестали бути evidence
- без верифікованих цитат агент деградує до: “не можу надійно процитувати”
Суха мораль: якщо не зберіг 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)
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)
Використовується в патернах
Пов’язані відмови
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 лінків)
- Foundations: Як агенти використовують tools · Як ліміти LLM впливають на агентів
- Failure: Prompt injection · Infinite loop
- Governance: Tool permissions (allowlists)
- Production stack: Production agent stack