Уявіть: ваш AI агент отримав запит «яка ціна на Enterprise план?».
Він викликав tool. Tool відповів. Агент сформулював відповідь — впевнено,
зв'язно, з конкретною цифрою. Клієнт отримав відповідь і пішов задоволений.
Проблема в тому що tool повернув порожній результат —
документ не знайшовся. А агент замість того щоб сказати «я не знайшов» —
вигадав ціну з власних тренувальних даних. Впевнено. Без жодного попередження.
Це не баг. Це відсутність grounding — і саме про це стаття.
Ця стаття — частина серії про AI агентів на Spring Boot.
Якщо ви ще не читали як модель вирішує коли викликати tool —
почніть з Як LLM вирішує коли викликати tool.
Зміст
Що таке grounding і чому його немає в туторіалах
Більшість туторіалів про AI агентів виглядають так:
1. Отримай запит від користувача
2. Виклич tool
3. Передай результат моделі
4. Отримай відповідь
5. ???
6. Profit
Крок 5 — це і є grounding. І туторіали його пропускають бо він
«нецікавий». Немає ефектного демо. Немає wow-моменту.
Просто нудна перевірка якості перед тим як відповідати.
Grounding — це процес де агент перевіряє чи результат
який повернув tool дійсно відповідає на запит, чи він достатньо якісний
щоб будувати на ньому відповідь, і чи може він послатись на конкретне
джерело а не вигадати відповідь.
Чому це критично — аналогія з реального життя
Уявіть що ви найняли асистента і попросили:
«Знайди мені умови нашого договору з клієнтом Альфа».
Асистент пішов в архів. Повернувся через хвилину. І розповів вам
умови договору — впевнено і детально.
Але насправді він нічого не знайшов в архіві. Просто згадав схожий
договір з іншим клієнтом і вирішив що «приблизно так само».
Ви підписали нову угоду на основі цих умов. І тільки через тиждень
виявили що умови були зовсім інші.
Саме так поводиться AI агент без grounding. І саме тому це не
академічна концепція — це питання довіри до вашого продукту.
Реальний кейс з AskYourDocs: на початку розробки агент
іноді відповідав на запити про конкретні документи навіть коли документ
не був завантажений у базу знань. Модель «згадувала» схожий контент
з тренувальних даних і подавала його як результат пошуку по документах
клієнта. Після впровадження grounding перевірок — ця проблема зникла.
Три сценарії поганого tool result — з реальними прикладами
Не всі погані результати однакові. Розберемо три принципово різні
сценарії — кожен вимагає окремої стратегії обробки. І кожен з них
може призвести до різного типу помилки якщо не обробляти явно.
Сценарій 1: Порожній результат
Tool виконався успішно але нічого не знайшов. Здається —
найпростіший випадок. Насправді — найчастіший привід для галюцинацій.
Чому це небезпечно: модель бачить порожню відповідь
і не знає що з нею робити. Замість того щоб сказати «не знайшов» —
вона заповнює пробіл власними тренувальними знаннями. Впевнено.
Без жодного попередження.
// Tool повернув порожній список
{
"results": [],
"total": 0
}
// Що модель робить БЕЗ grounding:
// User: "Яка ціна на Enterprise план?"
// Agent: "Згідно з корпоративними стандартами,
// ціна Enterprise плану складає $500/місяць..."
// (вигадала з тренувальних даних — реальна ціна $850)
// Що модель робить З grounding:
// Agent: "Я не знайшов інформації про ціни в базі знань.
// Рекомендую звернутись до відділу продажів."
Як обробляти в Java:
@Tool(description = "Шукає інформацію в базі знань")
public String searchKnowledgeBase(String query) {
List<SearchResult> results = vectorStore.search(query);
// Явно обробляємо порожній результат
if (results == null || results.isEmpty()) {
log.warn("Empty result for query: '{}'", query);
// Не повертаємо порожній рядок — повертаємо явну інструкцію
return String.format("""
РЕЗУЛЬТАТ: нічого не знайдено для запиту "%s"
ІНСТРУКЦІЯ ДЛЯ МОДЕЛІ: не відповідай з власних знань.
Повідом користувача що інформація відсутня в базі знань.
""", query);
}
return formatResults(results);
}
Реальний приклад з Agent Chat: Wikipedia tool іноді
повертає порожній результат для вузькоспеціалізованих запитів — наприклад
«vibe coding productivity statistics 2025». Без grounding агент одразу
починав наводити статистику яку сам вигадав — і вона потрапляла в
діалог як «реальний факт». Після додавання явної інструкції в порожній
результат — агент коректно перемикається на Tavily або визнає що
не знайшов.
Сценарій 2: Нерелевантний результат
Tool знайшов щось — але не те що потрібно. Це найнебезпечніший
сценарій з трьох. Бо модель бачить «є результат» і впевнено будує
на ньому відповідь — не перевіряючи чи це дійсно те що запитував користувач.
Чому це небезпечно: семантичний пошук завжди
повертає щось найближче до запиту — навіть якщо справжнього документа
немає в базі. Score 0.61 означає «найкращий з того що є» —
але не означає «відповідає на питання».
// Запит: "умови розірвання договору з клієнтом Альфа"
// Tool повернув найближчий документ:
{
"results": [
{
"title": "Загальні умови розірвання договорів",
"content": "Розірвання договору можливе за 30 днів...",
"score": 0.61 // низька релевантність — загальний шаблон, не договір Альфа
}
]
}
// БЕЗ grounding модель відповість:
// "Договір з клієнтом Альфа можна розірвати за 30 днів попередження"
// (насправді в їхньому договорі — 60 днів і штрафні санкції)
// З grounding — перевіряємо score перед передачею в модель
Як обробляти в Java — перевірка порогу релевантності:
@Tool(description = "Шукає інформацію в базі знань")
public String searchKnowledgeBase(String query) {
List<SearchResult> results = vectorStore.search(query);
if (results == null || results.isEmpty()) {
return buildNotFoundMessage(query);
}
SearchResult best = results.get(0);
double score = best.getScore();
if (score >= 0.75) {
// Висока релевантність — передаємо впевнено
return String.format("""
РЕЗУЛЬТАТ (релевантність: висока):
%s
ДЖЕРЕЛО: %s
""", best.getContent(), best.getDocumentTitle());
} else if (score >= 0.55) {
// Середня релевантність — попереджаємо модель
return String.format("""
РЕЗУЛЬТАТ (релевантність: середня, score: %.2f):
%s
УВАГА ДЛЯ МОДЕЛІ: результат може не точно відповідати на запит.
Повідом користувача що відповідь може бути неточною.
ДЖЕРЕЛО: %s
""", score, best.getContent(), best.getDocumentTitle());
} else {
// Низька релевантність — краще визнати що не знайшли
log.warn("Low relevance score {:.2f} for query: '{}'", score, query);
return buildNotFoundMessage(query);
}
}
private String buildNotFoundMessage(String query) {
return String.format("""
РЕЗУЛЬТАТ: інформація не знайдена для запиту "%s"
ІНСТРУКЦІЯ: не відповідай з власних знань.
Повідом що інформація відсутня або запропонуй уточнити запит.
""", query);
}
Реальний приклад з AskYourDocs: клієнт запитував
про умови конкретного договору. Документ не був завантажений в базу —
але векторний пошук знаходив загальний шаблон договору зі score 0.63.
Без grounding агент відповідав умовами шаблону як якщо б це були умови
конкретного клієнта. Після додавання порогу 0.75 — агент коректно
відповідає що конкретний договір не знайдено.
Сценарій 3: Помилка виконання
Tool впав з exception. Зовнішній API недоступний. Timeout.
Rate limit вичерпано. Це технічна помилка — і вона найлегша
для обробки якщо зробити це правильно.
Чому це небезпечно без обробки: модель отримує
повідомлення про помилку і робить одне з двох — або ігнорує і відповідає
з власних знань, або переказує технічні деталі помилки прямо користувачу.
Обидва варіанти погані.
// Tool повернув помилку
{
"error": "Connection timeout after 5000ms to https://api.tavily.com",
"results": null
}
// БЕЗ grounding — варіант 1: модель ігнорує помилку
// "За даними останніх досліджень, vibe coding збільшує продуктивність на 40%..."
// (вигадала бо не знала що робити з помилкою)
// БЕЗ grounding — варіант 2: модель переказує помилку
// "Виникла помилка підключення до https://api.tavily.com після 5000ms timeout..."
// (технічні деталі які не мають бачити користувачі)
Як обробляти в Java — try-catch з явною grounding інструкцією:
@Tool(description = """
Шукає актуальну інформацію в інтернеті через Tavily.
Використовуй для свіжих новин і статистики.
""")
public String searchWeb(String query) {
log.info("Tavily search: '{}'", query);
if (apiKey == null || apiKey.isBlank()) {
// API ключ не налаштований — явна grounding інструкція
return """
ПОМИЛКА КОНФІГУРАЦІЇ: пошук недоступний.
ІНСТРУКЦІЯ: повідом користувача що онлайн пошук
наразі не налаштований. Не відповідай з власних знань.
""";
}
try {
TavilyResponse response = callTavilyApi(query);
if (response == null || response.results().isEmpty()) {
return buildNotFoundMessage(query);
}
return formatResults(response.results());
} catch (ResourceAccessException e) {
// Timeout або недоступність сервісу
log.warn("Tavily timeout for '{}': {}", query, e.getMessage());
return """
ТИМЧАСОВА ПОМИЛКА: сервіс пошуку недоступний.
ІНСТРУКЦІЯ: повідом користувача що пошук тимчасово
недоступний і запропонуй спробувати пізніше.
НЕ відповідай з власних знань замість пошуку.
""";
} catch (HttpClientErrorException e) {
// Rate limit або помилка авторизації
log.error("Tavily API error for '{}': {} {}",
query, e.getStatusCode(), e.getMessage());
return """
ПОМИЛКА API: ліміт запитів вичерпано.
ІНСТРУКЦІЯ: повідом користувача що пошук
тимчасово обмежений. Спробуй відповісти
тільки якщо маєш точні знання з теми.
""";
} catch (Exception e) {
log.error("Unexpected Tavily error for '{}': {}", query, e.getMessage());
return """
НЕВІДОМА ПОМИЛКА пошуку.
ІНСТРУКЦІЯ: повідом користувача що виникла
технічна проблема. Не відповідай з власних знань.
""";
}
}
Зверніть увагу на патерн: кожен catch блок повертає
різну grounding інструкцію залежно від типу помилки. Rate limit —
можна спробувати відповісти якщо є точні знання. Timeout — краще
не відповідати взагалі. Це тонка але важлива різниця яка впливає
на якість відповідей агента.
| Сценарій |
Що повертає tool |
Ризик без grounding |
Стратегія обробки |
Пріоритет |
| Порожній результат |
results: [] |
Галюцинація з тренувальних даних |
Явна інструкція в контенті |
🔴 Критичний |
| Нерелевантний результат |
results з score < 0.55 |
Відповідь на неправильне питання |
Перевірка порогу + попередження |
🔴 Критичний |
| Помилка виконання |
exception / timeout |
Технічні деталі у відповіді |
try-catch з окремими інструкціями |
🟠 Високий |
Підводний камінь: не використовуйте порожній рядок
як повернення при помилці. Модель інтерпретує порожній рядок
по-різному залежно від контексту — іноді як «нічого не знайдено»,
іноді як «можна відповідати самостійно». Завжди повертайте явну
текстову інструкцію що саме сталось і що модель має робити далі.
Що модель робить з поганим результатом
Коли модель отримує поганий tool result — вона не зупиняється і не
питає «що робити далі». Вона продовжує генерувати відповідь.
Завжди. Автоматично. І робить це одним з трьох способів —
кожен з яких має різний рівень небезпеки і різний спосіб діагностики.
Варіант A: Ігнорує і відповідає з пам'яті
Найпоширеніший і найнебезпечніший варіант. Модель бачить порожній
або нерелевантний результат — і «вирішує» що краще відповісти
з власних тренувальних знань ніж визнати незнання.
Відповідь звучить впевнено і зв'язно. Жодного попередження.
Чому це найнебезпечніший варіант: помилку
неможливо виявити з відповіді. Немає «я не впевнений». Немає
«можливо». Просто впевнена відповідь яка може бути повністю вигаданою.
// Ситуація: клієнт запитує про ціну, документ не знайдено
// Tool повернув: results: []
// Внутрішній процес моделі (спрощено):
// "Результат пошуку порожній...
// Але я навчався на тисячах SaaS прайсингових сторінок.
// Знаю що Enterprise плани зазвичай коштують $300-800/місяць.
// Відповім на основі загальних знань. Звучатиме переконливо."
// Що отримує користувач:
// "Enterprise план включає необмежену кількість користувачів
// і коштує $500 на місяць при річній оплаті."
// stop_reason: "end_turn" — жодного tool_use в логах після порожнього результату
// Реальна ціна: $850/місяць після підвищення 2 місяці тому
Як виявити в логах: це єдиний варіант де
діагностика потребує активних зусиль. Відповідь виглядає нормально —
проблема видна тільки якщо логувати весь цикл tool calling.
// Що шукати в логах щоб виявити Варіант A:
// 1. Tool виклик є
// 2. Tool result є але порожній або з низьким score
// 3. stop_reason == "end_turn" — модель відповіла без повторного tool call
// 4. Відповідь містить конкретні цифри або факти яких немає в tool result
// Додайте в AgentConversationRunner:
log.info("Tool result length: {} chars, stop_reason: {}",
toolResult.length(),
response.getStopReason());
if (toolResult.isBlank() && response.getStopReason().equals("end_turn")) {
log.warn("GROUNDING RISK: empty tool result but model answered directly. " +
"Query: '{}'", userQuery);
}
Приклад з Agent Chat: в одному з тестових
діалогів агент наводив статистику «за даними дослідження Stanford» —
але Wikipedia tool повернув порожній результат для цього запиту.
Модель просто «згадала» схожу статистику з тренувальних даних і
подала її як реальний факт. В логах це виглядало як нормальний
успішний запит.
Варіант B: Будує відповідь на нерелевантному результаті
Модель бачить що щось знайдено — і приймає це як достатню основу.
Семантична схожість між запитом і результатом «обманює» модель.
Вона не перевіряє чи документ дійсно відповідає на питання —
вона просто бачить «є текст → можна відповідати».
Чому це відбувається: модель навчена відповідати
на основі контексту. Якщо в контексті є текст про договори — вона
відповідає про договори. Вона не розрізняє «загальний шаблон»
і «конкретний договір клієнта» якщо обидва містять схожі слова.
// Запит: "які штрафні санкції в договорі з клієнтом Альфа?"
// Tool знайшов найближчий документ зі score 0.63:
{
"title": "Типовий договір про надання послуг v2.1",
"content": "У разі порушення умов договору — штраф 0.1% на день...",
"score": 0.63
}
// Внутрішній процес моделі:
// "Є результат про договори і штрафи. Відповім на його основі."
// Що отримує користувач:
// "Згідно з договором, штрафні санкції складають 0.1% на день..."
// Реальні умови договору з Альфа:
// штраф 0.5% на день + право розірвання після 5 днів прострочення
// (вони підписали нестандартні умови 6 місяців тому)
Як виявити в логах: цей варіант видно через score
метрику — але тільки якщо ви її логуєте.
// Логування score для кожного tool result
@Tool(description = "Шукає в базі знань")
public String search(String query) {
List<SearchResult> results = vectorStore.search(query);
if (!results.isEmpty()) {
double score = results.get(0).getScore();
log.info("Search score for '{}': {:.3f} — {}",
query, score, results.get(0).getTitle());
// Алерт якщо score підозріло низький але результат є
if (score < 0.65) {
log.warn("LOW RELEVANCE SCORE {:.3f} for query: '{}'. " +
"Document: '{}'", score, query, results.get(0).getTitle());
}
}
// ... обробка результату
}
Варіант C: Визнає проблему (рідко і недетерміновано)
Frontier моделі — Claude Sonnet, GPT-4o, Gemini Pro — іноді
самостійно розпізнають що результат не відповідає на запит і
чесно про це кажуть. Це найкращий варіант поведінки.
Але є критична проблема: це відбувається непередбачувано.
Та сама модель на той самий запит може:
- Раз — визнати що не знайшла відповідь
- Інший раз — впевнено відповісти з вигаданими даними
Залежить від температури, від формулювання запиту,
від того що ще є в контексті. В production це неприйнятно.
// Той самий порожній результат — два різних запуски:
// Запуск 1 (модель "вирішила" визнати):
// "На жаль, я не знайшов конкретної інформації про ціни
// в доступних документах. Рекомендую звернутись до менеджера."
// Запуск 2 (та сама модель, той самий запит):
// "Enterprise план зазвичай включає необмежений доступ
// і коштує від $400 до $800 на місяць залежно від кількості користувачів."
// Різниця між запусками: лише temperature=0.7 замість temperature=0.3
// Grounding зробив би обидва запуски однаковими і передбачуваними
Що робити замість того щоб покладатись на Варіант C:
// НЕ робіть так — сподіватись що модель сама розбереться:
String systemPrompt = "Якщо не знаєш відповіді — скажи що не знаєш";
// Це працює іноді. Не завжди. Не в production.
// РОБІТЬ так — явна grounding інструкція в tool result:
return String.format("""
СТАТУС ПОШУКУ: результат не знайдено для запиту "%s"
ОБОВ'ЯЗКОВА ІНСТРУКЦІЯ:
- НЕ відповідай з власних тренувальних знань
- НЕ вигадуй цифри або факти
- Повідом користувача що інформація відсутня в базі
- Запропонуй альтернативу: уточнити запит або звернутись до підтримки
""", query);
// Це працює завжди. Детерміновано. В production.
Ключовий висновок: модель не знає що її tool result
«поганий» якщо ви їй це не сказали явно. Вона бачить лише рядок тексту.
Ваш код знає контекст — score релевантності, статус помилки,
чи результат порожній. Передавайте цю інформацію явно через
структурований контент tool result — не через загальні інструкції
в системному промпті. Різниця між «скажи що не знаєш» в системному
промпті і «СТАТУС: не знайдено» прямо в tool result —
це різниця між «іноді працює» і «завжди працює».
Таблиця діагностики: як визначити який варіант відбувся
| Варіант |
Що видно в логах |
Що видно у відповіді |
Як виявити |
| A: Відповідає з пам'яті |
tool result порожній, stop_reason = end_turn |
Впевнена відповідь з конкретними даними |
🔴 Складно — потрібне логування циклу |
| B: Нерелевантний результат |
tool result є, score < 0.65 |
Відповідь схожа але не точна |
🟠 Середньо — потрібне логування score |
| C: Визнає проблему |
tool result порожній, stop_reason = end_turn |
«Не знайшов», «рекомендую уточнити» |
🟢 Легко — видно з відповіді |
is_error: true vs порожній контент — різниця яка має значення
Spring AI і більшість LLM API підтримують поле is_error
в tool result. Більшість розробників ігнорують його — передають або
порожній рядок або текст помилки без прапорця. І це одна з найпоширеніших
причин чому агент поводиться непередбачувано при помилках.
Як модель технічно читає tool result
Щоб зрозуміти різницю — потрібно знати як tool result потрапляє
в контекст моделі. Це не просто рядок тексту. Це структурований блок
з метаданими:
// Що LLM бачить всередині контексту (спрощено):
{
"role": "tool",
"tool_use_id": "toolu_01ABC",
"content": "...", // текст результату
"is_error": false // або true
}
// Модель навчена розрізняти ці два стани:
// is_error: false → "це нормальний результат, використовуй для відповіді"
// is_error: true → "щось пішло не так, не будуй відповідь на цьому"
Саме тому is_error: true — це не просто семантика.
Це інструкція яку модель отримала під час fine-tuning:
результат з цим прапорцем означає що пошук не вдався
і відповідати з власних знань не варто.
Порожній контент — модель вирішує сама
Якщо повернути порожній рядок без is_error —
модель отримує неоднозначний сигнал. Вона бачить «tool відповів,
але нічого не повернув» і самостійно вирішує як це інтерпретувати.
І це рішення непередбачуване.
// ❌ Так робити не варто — модель вирішує сама
ToolResponseMessage emptyResult = new ToolResponseMessage(
toolCallId,
"" // порожній рядок без is_error
);
// Модель може інтерпретувати це як:
// - "нічого не знайдено — скажу що не знаю" (правильно, але рідко)
// - "результат ще завантажується — почекаю" (невірно)
// - "можна відповідати з власних знань" (небезпечно — найчастіший варіант)
// - "tool зламався — поскаржусь технічно" (поганий UX)
// Та сама ситуація може давати різні результати при різних запусках
is_error: true — явна детермінована інструкція
// ✅ Так правильно — явний сигнал для моделі
ToolResponseMessage errorResult = new ToolResponseMessage(
toolCallId,
"Документ не знайдено в базі знань для запиту: умови договору Альфа",
true // is_error — модель знає що робити
);
// Модель отримує чіткий сигнал:
// - це явна помилка, не відсутній результат
// - не будуй відповідь на цій основі
// - повідом користувача про проблему
// - не намагайся «заповнити» відповідь з власних знань
Повний GroundedToolResultBuilder — чотири стани
На практиці у tool result є чотири різних стани —
кожен вимагає окремої обробки:
@Service
@Slf4j
public class GroundedToolResultBuilder {
/**
* Успішний результат з високою релевантністю (score >= 0.75)
* is_error: false — модель може будувати відповідь
*/
public ToolResponseMessage success(String toolCallId,
String content,
String documentTitle,
String sourceRef) {
String groundedContent = String.format("""
СТАТУС: РЕЗУЛЬТАТ ЗНАЙДЕНО
ЗМІСТ:
%s
ДЖЕРЕЛО: %s
ДОКУМЕНТ: %s
ІНСТРУКЦІЯ: використовуй цей результат для відповіді.
Обов'язково вкажи джерело у форматі "Згідно з [документ]..."
""", content, sourceRef, documentTitle);
log.info("Tool result: SUCCESS, doc='{}'", documentTitle);
return new ToolResponseMessage(toolCallId, groundedContent);
// is_error за замовчуванням false
}
/**
* Результат знайдено але релевантність середня (score 0.55-0.75)
* is_error: false — але з попередженням для моделі
*/
public ToolResponseMessage lowRelevance(String toolCallId,
String content,
String documentTitle,
double score) {
String groundedContent = String.format("""
СТАТУС: РЕЗУЛЬТАТ З НИЗЬКОЮ РЕЛЕВАНТНІСТЮ (score: %.2f)
ЗМІСТ:
%s
ДОКУМЕНТ: %s
УВАГА: результат може не точно відповідати на запит користувача.
Використовуй з обережністю. Повідом користувача що відповідь
може бути неточною і рекомендуй уточнити запит.
""", score, content, documentTitle);
log.warn("Tool result: LOW_RELEVANCE, score={:.2f}, doc='{}'",
score, documentTitle);
return new ToolResponseMessage(toolCallId, groundedContent);
}
/**
* Нічого не знайдено в базі знань
* is_error: true — критично щоб модель не відповідала з пам'яті
*/
public ToolResponseMessage notFound(String toolCallId, String query) {
String message = String.format("""
СТАТУС: РЕЗУЛЬТАТ НЕ ЗНАЙДЕНО
Запит: "%s"
ОБОВ'ЯЗКОВА ІНСТРУКЦІЯ:
- НЕ відповідай з власних тренувальних знань
- НЕ вигадуй факти або цифри
- Повідом користувача що інформація відсутня в базі знань
- Запропонуй уточнити запит або звернутись до підтримки
""", query);
log.warn("Tool result: NOT_FOUND, query='{}'", query);
return new ToolResponseMessage(toolCallId, message, true); // is_error: true
}
/**
* Технічна помилка — API недоступний, timeout тощо
* is_error: true — модель має повідомити про технічну проблему
*/
public ToolResponseMessage technicalError(String toolCallId,
String errorMessage) {
String message = String.format("""
СТАТУС: ТЕХНІЧНА ПОМИЛКА ПОШУКУ
Деталі (тільки для логів, не показуй користувачу): %s
ІНСТРУКЦІЯ:
- Повідом користувача що пошук тимчасово недоступний
- НЕ передавай технічні деталі помилки користувачу
- НЕ відповідай з власних знань замість пошуку
- Запропонуй спробувати пізніше
""", errorMessage);
log.error("Tool result: TECHNICAL_ERROR — {}", errorMessage);
return new ToolResponseMessage(toolCallId, message, true); // is_error: true
}
}
Порівняльна таблиця: який стан коли використовувати
| Стан |
is_error |
Коли використовувати |
Що робить модель |
| success() |
false |
score >= 0.75, документ знайдено |
Будує відповідь, вказує джерело |
| lowRelevance() |
false |
score 0.55–0.75, щось знайшлось |
Відповідає з застереженням |
| notFound() |
true |
results порожній або score < 0.55 |
Повідомляє що не знайшла |
| technicalError() |
true |
Exception, timeout, API помилка |
Повідомляє про технічну проблему |
Як перевірити що is_error реально працює
Додайте простий тест щоб переконатись що модель справді
поводиться по-різному залежно від is_error:
@SpringBootTest
class IsErrorBehaviorTest {
@Autowired
private ChatModel chatModel;
@Test
void modelShouldNotHallucinateWhenIsErrorTrue() {
// Симулюємо порожній результат з is_error: true
List<Message> messages = List.of(
new SystemMessage("Відповідай тільки на основі результатів пошуку."),
new UserMessage("Яка ціна на Enterprise план?"),
new AssistantMessage(""), // placeholder
new ToolResponseMessage(
"test-id",
"СТАТУС: РЕЗУЛЬТАТ НЕ ЗНАЙДЕНО\n" +
"ІНСТРУКЦІЯ: не відповідай з власних знань.",
true // is_error: true
)
);
String response = chatModel.call(new Prompt(messages))
.getResult().getOutput().getText();
// Модель НЕ має вигадувати ціну
assertThat(response)
.doesNotContain("$")
.doesNotContain("500")
.doesNotContain("місяць")
.containsAnyOf("не знайшов", "відсутня", "не знайдено");
}
@Test
void modelShouldAnswerWhenIsErrorFalse() {
// Симулюємо успішний результат
List<Message> messages = List.of(
new SystemMessage("Відповідай тільки на основі результатів пошуку."),
new UserMessage("Яка ціна на Enterprise план?"),
new AssistantMessage(""),
new ToolResponseMessage(
"test-id",
"СТАТУС: РЕЗУЛЬТАТ ЗНАЙДЕНО\n" +
"ЗМІСТ: Enterprise план — $850/місяць при річній оплаті.\n" +
"ДЖЕРЕЛО: Прайслист v2.3",
false // is_error: false
)
);
String response = chatModel.call(new Prompt(messages))
.getResult().getOutput().getText();
// Модель МАЄ використати цифру з результату
assertThat(response)
.contains("850")
.containsAnyOf("Згідно з", "прайслист", "документ");
}
}
Практична порада для Spring AI: в Spring AI 2.0.x
конструктор ToolResponseMessage(id, content, isError)
може відрізнятись залежно від версії — перевірте сигнатуру в
вашому M3/M5. Якщо конструктор з трьома параметрами недоступний —
використовуйте builder або передавайте is_error через контент
з явною ІНСТРУКЦІЄЮ для моделі. Явний текст в контенті
("НЕ відповідай з власних знань") працює майже так само надійно
як прапорець — і є запасним варіантом для будь-якої версії Spring AI.
Confidence scoring — як попросити модель оцінити якість
Confidence scoring — це техніка де ми просимо модель явно оцінити
наскільки знайдений результат відповідає на запит, перш ніж будувати
фінальну відповідь. Це додатковий крок — але він закриває сліпу зону
яку не закриває векторний score.
Навіщо це потрібно — і чому векторного score недостатньо
Ваш код знає технічні метрики — score векторного пошуку, кількість
знайдених документів. Але він не знає семантичної відповідності.
Ось конкретний приклад де векторний score брехав:
// Запит: "штрафні санкції за прострочення оплати в договорі з Бета Corp"
// Векторний пошук повернув:
{
"title": "Договір з Бета Corp — основні умови",
"score": 0.82, // висока релевантність!
"content": "Сторони домовились про наступні умови співпраці:
терміни виконання, порядок оплати, відповідальність сторін..."
}
// Документ правильний — але розділ про санкції в іншому місці
// Векторний score 0.82 — бо документ дійсно про цей договір
// Але ВІДПОВІДІ на питання про санкції тут немає
// Confidence scoring виявить це:
// { "confidence": "LOW", "reason": "документ знайдено але санкції не описані",
// "can_answer": false }
Модель краще розуміє семантику ніж числовий score —
попросіть її перевірити перед тим як відповідати.
Коли використовувати confidence scoring
| Тип запиту |
Використовувати? |
Причина |
| Ціни, тарифи |
✅ Так |
Помилка = фінансові наслідки |
| Умови договорів |
✅ Так |
Помилка = юридичні наслідки |
| Конкретні дати, дедлайни |
✅ Так |
Точність критична |
| Загальні FAQ питання |
⚠️ Опціонально |
Достатньо векторного score |
| Простий пошук по темі |
❌ Ні |
Додає latency без суттєвої користі |
Реалізація на Spring AI
@Service
@RequiredArgsConstructor
@Slf4j
public class ConfidenceScoringService {
private final ChatModel chatModel;
private static final String CONFIDENCE_PROMPT = """
Оціни наскільки знайдений результат відповідає на запит користувача.
ЗАПИТ КОРИСТУВАЧА: %s
ЗНАЙДЕНИЙ РЕЗУЛЬТАТ:
%s
Відповідай ТІЛЬКИ у форматі JSON без пояснень, без markdown, без ```json:
{
"confidence": "HIGH" | "MEDIUM" | "LOW" | "NOT_RELEVANT",
"reason": "одне речення чому",
"can_answer": true | false
}
Критерії оцінки:
- HIGH: результат прямо і повно відповідає на запит — відповідай впевнено
- MEDIUM: результат частково відповідає — відповідай з застереженням
- LOW: результат пов'язаний з темою але не відповідає конкретно — краще визнати
- NOT_RELEVANT: результат не стосується запиту — не відповідай
""";
public ConfidenceResult evaluate(String userQuery, String toolResult) {
// Не оцінюємо якщо результат явно порожній
if (toolResult == null || toolResult.isBlank()) {
return ConfidenceResult.notFound();
}
String prompt = String.format(CONFIDENCE_PROMPT, userQuery, toolResult);
try {
String response = chatModel.call(prompt).trim();
// Прибираємо можливі markdown backticks які деякі моделі додають
// незважаючи на інструкцію (deepseek, llama схильні до цього)
String cleanJson = response
.replaceAll("```json", "")
.replaceAll("```", "")
.trim();
ObjectMapper mapper = new ObjectMapper();
JsonNode json = mapper.readTree(cleanJson);
ConfidenceLevel level = ConfidenceLevel.valueOf(
json.get("confidence").asText());
String reason = json.get("reason").asText();
boolean canAnswer = json.get("can_answer").asBoolean();
log.info("Confidence evaluated: level={}, canAnswer={}, reason='{}'",
level, canAnswer, reason);
return ConfidenceResult.builder()
.level(level)
.reason(reason)
.canAnswer(canAnswer)
.build();
} catch (JsonProcessingException e) {
// Модель повернула не-JSON — логуємо і використовуємо безпечний дефолт
log.warn("Failed to parse confidence JSON response. " +
"Raw response: '{}'. Defaulting to LOW.",
chatModel.call(prompt));
return ConfidenceResult.safe(); // LOW + canAnswer: false
} catch (IllegalArgumentException e) {
// Невідомий ConfidenceLevel у відповіді
log.warn("Unknown confidence level in response. Defaulting to LOW.");
return ConfidenceResult.safe();
}
}
}
@Value
@Builder
public class ConfidenceResult {
ConfidenceLevel level;
String reason;
boolean canAnswer;
// Фабричні методи для типових станів
public static ConfidenceResult notFound() {
return ConfidenceResult.builder()
.level(ConfidenceLevel.NOT_RELEVANT)
.reason("Результат порожній")
.canAnswer(false)
.build();
}
public static ConfidenceResult safe() {
return ConfidenceResult.builder()
.level(ConfidenceLevel.LOW)
.reason("Не вдалось оцінити релевантність")
.canAnswer(false)
.build();
}
}
public enum ConfidenceLevel {
HIGH, MEDIUM, LOW, NOT_RELEVANT
}
Як виглядає реальний результат confidence scoring
Ось три реальні приклади з AskYourDocs — що повертає
confidence scoring на різних запитах:
// Приклад 1: HIGH confidence — відповідь є прямо в документі
Запит: "яка ціна на Basic план?"
Результат пошуку: "Basic план — $49/місяць, до 5 користувачів..."
Confidence: {
"confidence": "HIGH",
"reason": "документ містить пряму відповідь на питання про ціну",
"can_answer": true
}
→ Агент відповідає впевнено з цитатою
// Приклад 2: LOW confidence — документ є але відповіді немає
Запит: "штрафи за прострочення оплати"
Результат пошуку: "Договір про надання послуг. Розділ 3: Порядок оплати..."
Confidence: {
"confidence": "LOW",
"reason": "документ про оплату але штрафні санкції не описані в цьому розділі",
"can_answer": false
}
→ Агент повідомляє що точної інформації не знайшов
// Приклад 3: NOT_RELEVANT — пошук знайшов не той документ
Запит: "умови договору з клієнтом Гамма"
Результат пошуку: "Загальні умови надання послуг v1.0..."
Confidence: {
"confidence": "NOT_RELEVANT",
"reason": "знайдено загальний шаблон, а не договір конкретного клієнта",
"can_answer": false
}
→ Агент повідомляє що договір Гамма не знайдено в базі
Як використовувати в агентному pipeline
@Service
@RequiredArgsConstructor
@Slf4j
public class GroundedAgentService {
private final ConfidenceScoringService confidenceScoring;
private final GroundedToolResultBuilder resultBuilder;
private final ChatModel chatModel;
public String answerWithGrounding(String userQuery,
String toolResult,
String toolCallId,
String documentTitle) {
// 1. Оцінюємо релевантність через LLM
ConfidenceResult confidence = confidenceScoring
.evaluate(userQuery, toolResult);
log.info("Grounding decision for '{}': {} (canAnswer={})",
userQuery, confidence.getLevel(), confidence.isCanAnswer());
// 2. Вибираємо стратегію на основі confidence
ToolResponseMessage groundedResult = switch (confidence.getLevel()) {
case HIGH ->
resultBuilder.success(toolCallId, toolResult, documentTitle, "knowledge_base");
case MEDIUM ->
resultBuilder.lowRelevance(toolCallId, toolResult, documentTitle, 0.65);
case LOW, NOT_RELEVANT ->
resultBuilder.notFound(toolCallId, userQuery);
};
// 3. Передаємо в модель з grounding інструкціями
List<Message> messages = List.of(
new SystemMessage("""
Відповідай ТІЛЬКИ на основі наданих результатів пошуку.
Якщо результат позначений як 'не знайдено' — повідом про це.
Завжди вказуй джерело інформації.
"""),
new UserMessage(userQuery),
new AssistantMessage(""),
groundedResult
);
return chatModel.call(new Prompt(messages))
.getResult()
.getOutput()
.getText();
}
}
Вартість і latency confidence scoring: це додатковий
LLM запит — ~150-300 токенів на вхід і ~50 токенів на вихід.
З deepseek-chat через OpenRouter це приблизно $0.0001-0.0003
на один запит. Latency — 200-500ms залежно від моделі і провайдера.
Для критичних запитів (ціни, договори) — ця ціна виправдана.
Для простих інформаційних запитів де помилка не має серйозних
наслідків — достатньо векторного score >= 0.75 і можна пропустити
confidence scoring повністю.
Re-query патерн — коли спробувати ще раз
Якщо перший пошук не дав хорошого результату — чи варто спробувати
ще раз з іншим запитом? Відповідь: так, але тільки в конкретних
ситуаціях і з жорстким лімітом спроб.
Re-query — це не «пошукай ще раз те саме». Це переформулювання
запиту на основі того що перший пошук не знайшов. Різниця принципова.
Flow re-query циклу
Запит користувача
↓
Пошук (спроба 1)
↓
Є результат?
├── НІ → Переформулюй запит → Пошук (спроба 2)
│ ↓
│ Є результат?
│ ├── НІ → notFound()
│ └── ТАК → Confidence scoring
│ ↓
│ HIGH/MEDIUM → found()
│ LOW → notFound()
│ NOT_RELEVANT → notFound()
└── ТАК → Confidence scoring
↓
HIGH/MEDIUM → found()
LOW → Переформулюй → Пошук (спроба 2)
NOT_RELEVANT → notFound() (без re-query — документа немає)
Коли re-query виправданий
- Перший запит занадто специфічний —
«штрафні санкції договір Альфа Corp пункт 5.2» →
краще «умови відповідальності Альфа Corp»
- Запит містить технічний жаргон —
«SLA uptime guarantee» → «гарантія доступності сервісу»
- Confidence LOW але не NOT_RELEVANT —
документ пов'язаний з темою але не той розділ.
Є шанс знайти потрібний розділ з іншим запитом
Коли re-query не допоможе
- Confidence NOT_RELEVANT —
пошук знайшов документ на зовсім іншу тему.
Документа якого шукають просто немає в базі.
Переформулювання не допоможе
- Tool повернув технічну помилку —
проблема в інфраструктурі а не в запиті.
Re-query тільки збільшить кількість помилкових запитів
- Вже спробували MAX_ATTEMPTS варіантів —
зупиняємось і визнаємо що не знайшли
Реальні приклади переформулювання
Ось як LLM переформульовує запити на практиці —
з реальних логів AskYourDocs:
// Приклад 1: занадто специфічний запит
Оригінал: "пункт 8.3.2 договору послуги хмарного зберігання Бета Corp"
Спроба 2: "умови зберігання даних Бета Corp"
Результат: знайдено потрібний розділ зі score 0.81 ✅
// Приклад 2: технічний жаргон
Оригінал: "RTO і RPO SLA enterprise tier"
Спроба 2: "гарантії відновлення після збоїв enterprise план"
Результат: знайдено документ про SLA зі score 0.79 ✅
// Приклад 3: документа немає — re-query не допомагає
Оригінал: "договір з клієнтом Гамма LLC"
Confidence: NOT_RELEVANT (знайшовся договір з іншим клієнтом)
Рішення: одразу notFound() без re-query —
немає сенсу переформульовувати якщо документа не існує ✅
// Приклад 4: re-query теж не знайшов
Оригінал: "знижки для освітніх установ"
Спроба 2: "пільгові умови для університетів і шкіл"
Результат: обидва порожні → notFound()
(прайсингова політика для edu не була завантажена в базу) ✅
Реалізація з лімітом спроб
@Service
@RequiredArgsConstructor
@Slf4j
public class ReQueryService {
private static final int MAX_ATTEMPTS = 2;
private final KnowledgeBaseSearchTool searchTool;
private final ConfidenceScoringService confidenceScoring;
private final ChatModel chatModel;
public SearchResult searchWithRequery(String originalQuery) {
String currentQuery = originalQuery;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
log.info("Search attempt {}/{}: '{}'",
attempt, MAX_ATTEMPTS, currentQuery);
String result = searchTool.search(currentQuery);
// Порожній результат — одразу re-query якщо є спроби
if (result == null || result.isBlank()) {
if (attempt < MAX_ATTEMPTS) {
currentQuery = reformulateQuery(originalQuery, attempt);
continue;
}
log.warn("All {} attempts exhausted, query: '{}'",
MAX_ATTEMPTS, originalQuery);
return SearchResult.notFound(originalQuery);
}
// Оцінюємо семантичну релевантність
ConfidenceResult confidence = confidenceScoring
.evaluate(originalQuery, result);
log.info("Attempt {} confidence: {} — {}",
attempt, confidence.getLevel(), confidence.getReason());
switch (confidence.getLevel()) {
case HIGH, MEDIUM -> {
// Знайшли — повертаємо результат
return SearchResult.found(result, confidence, currentQuery);
}
case NOT_RELEVANT -> {
// Документа немає — re-query не допоможе
log.info("NOT_RELEVANT result, skipping re-query for: '{}'",
originalQuery);
return SearchResult.notFound(originalQuery);
}
case LOW -> {
// Є шанс знайти краще — спробуємо ще раз
if (attempt < MAX_ATTEMPTS) {
currentQuery = reformulateQuery(originalQuery, attempt);
}
}
}
}
log.warn("Re-query exhausted for: '{}'", originalQuery);
return SearchResult.notFound(originalQuery);
}
private String reformulateQuery(String originalQuery, int attempt) {
String prompt = String.format("""
Пошуковий запит не знайшов релевантного результату: "%s"
Переформулюй запит — простіше, ширше, без жаргону.
Це спроба %d переформулювання.
Відповідай ТІЛЬКИ новим запитом без пояснень і лапок.
""", originalQuery, attempt);
String reformulated = chatModel.call(prompt).trim()
.replaceAll("\"", ""); // прибираємо лапки якщо модель їх додала
log.info("Reformulated: '{}' → '{}'", originalQuery, reformulated);
return reformulated;
}
}
SearchResult — клас результату
@Value
@Builder
public class SearchResult {
boolean found;
String content;
ConfidenceResult confidence;
String usedQuery; // який саме запит знайшов результат
int attemptsUsed; // скільки спроб знадобилось
public static SearchResult found(String content,
ConfidenceResult confidence,
String usedQuery,
int attempts) {
return SearchResult.builder()
.found(true)
.content(content)
.confidence(confidence)
.usedQuery(usedQuery)
.attemptsUsed(attempts)
.build();
}
public static SearchResult notFound(String originalQuery) {
return SearchResult.builder()
.found(false)
.content("")
.usedQuery(originalQuery)
.attemptsUsed(0)
.build();
}
}
Ліміт спроб і вартість — з мого досвіду:
я зупинився на MAX_ATTEMPTS = 2 після тестування. Спробував 3 —
третя спроба давала кращий результат менш ніж у 5% випадків.
Здебільшого якщо за дві спроби нічого не знайшлось — документа
просто немає в базі, і ніяке переформулювання не допоможе.
Кожна спроба це два додаткових LLM запити — reformulate +
confidence scoring. З deepseek-chat через OpenRouter це ~$0.0002-0.0005
на повний re-query цикл — але при 1000 запитів на день це вже
відчутна сума. Два — достатньо. Більше — витрати без результату.
Citation і traceability — агент має знати звідки відповідь
Citation — це не просто «додати посилання». Це архітектурний принцип:
агент повинен знати звідки кожна частина відповіді — і бути здатним
це показати. Без citation у вас немає способу перевірити чи агент
відповів на основі документів чи вигадав.
Чому citation важлива — дві причини
Причина 1: Бізнес і довіра. Уявіть юридичну фірму
що використовує AskYourDocs. Клієнт запитує про умови договору.
Агент відповідає. Адвокат потім питає:
«На якій сторінці якого документа це написано?»
Без citation — відповісти неможливо. І клієнт перестає довіряти системі.
З citation — агент відразу показує:
«Стаття 5.2 Договору №123 від 15.03.2025».
Причина 2: Дебаг і моніторинг. Коли агент дає
неправильну відповідь — як ви зрозумієте чому? Без citation ви бачите
тільки «агент відповів неправильно». З citation ви бачите конкретно:
«агент відповів на основі документу v1.2 який був замінений v2.0
три місяці тому». Це різниця між «щось зламалось» і «ось де саме».
// Без citation — ви бачите тільки результат:
User: "Який штраф за прострочення оплати?"
Agent: "Штраф складає 0.1% на день від суми заборгованості."
// Правильно? Неправильно? Звідки ця цифра? Невідомо.
// З citation — ви бачите повний ланцюжок:
User: "Який штраф за прострочення оплати?"
Agent: "Згідно з Договором про надання послуг v1.2 (розділ 7.3,
індексовано 12.01.2025), штраф складає 0.1% на день."
// Одразу видно: v1.2 — але зараз актуальна v2.0 де штраф 0.5%
// Проблема знайдена за секунди
Структура CitedSearchResult
@Value
@Builder
public class CitedSearchResult {
String content; // текст знайденого фрагменту
String documentTitle; // назва документу
String documentId; // ID в базі даних
String documentVersion; // версія документу якщо є
String pageOrSection; // сторінка або розділ
String sourceUrl; // посилання якщо є
double relevanceScore; // score векторного пошуку
LocalDateTime indexedAt; // коли документ був індексований в базу
LocalDateTime documentDate; // дата самого документу (може відрізнятись)
}
Як зберігати citation метадані в PostgreSQL
Citation працює тільки якщо метадані зберігаються разом
з векторними embeddings. Ось як це виглядає в схемі:
-- Таблиця документів з метаданими для citation
CREATE TABLE knowledge_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(500) NOT NULL,
version VARCHAR(50),
section VARCHAR(200),
source_url VARCHAR(1000),
document_date TIMESTAMP,
indexed_at TIMESTAMP DEFAULT NOW(),
is_active BOOLEAN DEFAULT TRUE -- важливо для застарілих документів
);
-- Таблиця чанків з посиланням на документ
CREATE TABLE document_chunks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID REFERENCES knowledge_documents(id),
content TEXT NOT NULL,
embedding vector(1536), -- pgvector
chunk_index INTEGER, -- номер чанку в документі
page_number INTEGER -- сторінка якщо є
);
-- Індекс для векторного пошуку
CREATE INDEX ON document_chunks
USING ivfflat (embedding vector_cosine_ops);
// Repository для пошуку з метаданими
@Repository
public interface DocumentChunkRepository extends JpaRepository<DocumentChunk, UUID> {
@Query(value = """
SELECT dc.*, kd.title, kd.version, kd.section,
kd.source_url, kd.document_date, kd.indexed_at,
1 - (dc.embedding <=> :embedding) as relevance_score
FROM document_chunks dc
JOIN knowledge_documents kd ON dc.document_id = kd.id
WHERE kd.is_active = TRUE
ORDER BY dc.embedding <=> :embedding
LIMIT :limit
""", nativeQuery = true)
List<ChunkWithMetadata> findWithCitation(
@Param("embedding") float[] embedding,
@Param("limit") int limit
);
}
Tool з повним citation
@Tool(description = """
Шукає інформацію в корпоративній базі знань.
Повертає результат з точним посиланням на джерело —
назву документу, розділ і дату актуальності.
""")
public String searchWithCitation(String query) {
// Генеруємо embedding для запиту
float[] queryEmbedding = embeddingModel.embed(query);
List<ChunkWithMetadata> results = chunkRepository
.findWithCitation(queryEmbedding, 5);
if (results.isEmpty()) {
return """
РЕЗУЛЬТАТ: нічого не знайдено
ДЖЕРЕЛО: відсутнє
ІНСТРУКЦІЯ: повідом що інформація не знайдена в базі знань
""";
}
ChunkWithMetadata best = results.get(0);
// Перевіряємо актуальність документу
boolean isRecent = best.getIndexedAt()
.isAfter(LocalDateTime.now().minusMonths(6));
String freshnessWarning = isRecent ? "" :
"\n⚠️ УВАГА: документ індексовано більше 6 місяців тому — " +
"рекомендуй користувачу підтвердити актуальність.";
return String.format("""
РЕЗУЛЬТАТ:
%s
ДЖЕРЕЛО (обов'язково вкажи у відповіді):
- Документ: %s%s
- Розділ: %s
- Сторінка: %s
- Посилання: %s
- Актуальність документу: %s
- Індексовано: %s
- Релевантність: %.0f%%
%s
ІНСТРУКЦІЯ: при відповіді обов'язково вкажи джерело у форматі
"Згідно з [назва документу], [розділ/сторінка]..."
""",
best.getContent(),
best.getTitle(),
best.getVersion() != null ? " (" + best.getVersion() + ")" : "",
best.getSection() != null ? best.getSection() : "не вказано",
best.getPageNumber() != null ? best.getPageNumber().toString() : "не вказано",
best.getSourceUrl() != null ? best.getSourceUrl() : "внутрішній документ",
best.getDocumentDate() != null
? best.getDocumentDate().toLocalDate().toString()
: "не вказано",
best.getIndexedAt().toLocalDate(),
best.getRelevanceScore() * 100,
freshnessWarning
);
}
Як виглядає відповідь з citation — три рівні деталізації
// Рівень 1 — без citation (так не треба робити):
"Договір можна розірвати за 30 днів попередження."
// Рівень 2 — базова citation:
"Згідно з Договором №123 (розділ 5.2),
розірвання можливе за 30 календарних днів письмового попередження."
// Рівень 3 — повна citation з датою (для критичних запитів):
"Згідно з Договором про надання послуг №123 v2.1 (розділ 5.2,
сторінка 8), розірвання можливе за 30 календарних днів письмового
попередження. [Документ актуальний на 15.03.2025,
індексовано 16.03.2025]"
Citation для Agent Chat — Wikipedia і Tavily
В Agent Chat агенти використовують зовнішні джерела — Wikipedia,
Tavily, NewsAPI. Citation тут особливо важлива бо читач хоче
перевірити факт самостійно.
// Погано — агент каже "за даними досліджень":
"За даними досліджень, GitHub Copilot підвищує продуктивність на 55%."
// Яких досліджень? Коли? Де перевірити?
// Добре — агент цитує конкретне джерело:
"Згідно з дослідженням GitHub (2023, опубліковано на github.blog),
розробники з Copilot виконують завдання на 55% швидше."
// Читач може перейти і перевірити
// Як додати citation в WikipediaSearchTool
@Tool(description = "Шукає факти у Wikipedia")
public String searchWikipedia(String query) {
WikiSearchResponse response = callWikipediaApi(query);
if (response.getResults().isEmpty()) {
return "Wikipedia: нічого не знайдено для запиту: " + query;
}
WikiResult result = response.getResults().get(0);
// Повертаємо результат з явним citation
return String.format("""
WIKIPEDIA РЕЗУЛЬТАТ:
%s
ДЖЕРЕЛО: Wikipedia, стаття "%s"
ПОСИЛАННЯ: https://uk.wikipedia.org/wiki/%s
ІНСТРУКЦІЯ: при відповіді вкажи джерело як
"За даними Wikipedia (стаття '%s')..."
""",
result.getSnippet(),
result.getTitle(),
result.getTitle().replace(" ", "_"),
result.getTitle()
);
}
Підводний камінь — застарілі документи: citation
показує коли документ був індексований — але не коли він був
оновлений в реальності. Документ індексований місяць тому може
містити інформацію дворічної давнини. Додайте в схему поле
document_date окремо від indexed_at
і показуйте обидва. Якщо document_date більше
6 місяців тому — попереджайте модель щоб вона повідомила
користувача про можливу застарілість.
Java + Spring AI реалізація — повний pipeline
Зберемо всі концепції в єдиний grounding pipeline який можна
використати в реальному проекті.
Структура компонентів
src/main/java/com/example/
├── grounding/
│ ├── GroundingPipeline.java // головний компонент
│ ├── ConfidenceScoringService.java // оцінка релевантності
│ ├── ReQueryService.java // повторний пошук
│ ├── CitationBuilder.java // формування citation
│ └── GroundedToolResultBuilder.java // формування tool result
├── model/
│ ├── ConfidenceResult.java
│ ├── SearchResult.java
│ └── CitedSearchResult.java
GroundingPipeline — головний компонент
@Service
@RequiredArgsConstructor
@Slf4j
public class GroundingPipeline {
private final ReQueryService reQueryService;
private final GroundedToolResultBuilder resultBuilder;
private final ChatModel chatModel;
private static final double HIGH_CONFIDENCE_THRESHOLD = 0.75;
private static final double LOW_CONFIDENCE_THRESHOLD = 0.50;
/**
* Основний метод — обробляє запит з повним grounding циклом
*/
public String processWithGrounding(String userQuery, String toolCallId) {
// 1. Шукаємо з можливим re-query
SearchResult searchResult = reQueryService
.searchWithRequery(userQuery);
log.info("Search result for '{}': found={}, confidence={}",
userQuery,
searchResult.isFound(),
searchResult.getConfidence());
// 2. Формуємо grounded tool result
ToolResponseMessage toolResponse = buildGroundedResponse(
toolCallId, userQuery, searchResult);
// 3. Передаємо в модель з системними інструкціями
List messages = buildMessagesWithGrounding(
userQuery, toolResponse);
// 4. Отримуємо фінальну відповідь
return chatModel.call(new Prompt(messages))
.getResult()
.getOutput()
.getText();
}
private ToolResponseMessage buildGroundedResponse(
String toolCallId,
String query,
SearchResult result) {
if (!result.isFound()) {
return resultBuilder.notFound(toolCallId, query);
}
double score = result.getConfidence().getScore();
if (score >= HIGH_CONFIDENCE_THRESHOLD) {
return resultBuilder.success(
toolCallId,
result.getContent(),
result.getSourceReference()
);
} else if (score >= LOW_CONFIDENCE_THRESHOLD) {
return resultBuilder.lowRelevance(
toolCallId,
result.getContent(),
score
);
} else {
return resultBuilder.notFound(toolCallId, query);
}
}
private List buildMessagesWithGrounding(
String userQuery,
ToolResponseMessage toolResponse) {
return List.of(
new SystemMessage("""
Ти корпоративний асистент що відповідає на основі документів компанії.
ПРАВИЛА GROUNDING:
1. Відповідай ТІЛЬКИ на основі результатів пошуку
2. Якщо результат позначений як "не знайдено" — скажи що не знайшов
3. Завжди вказуй джерело у форматі: "Згідно з [документ]..."
4. Якщо впевненість LOW — попереджай: "Знайдена інформація може бути неточною"
5. НІКОЛИ не вигадуй інформацію якої немає в результатах пошуку
"""),
new UserMessage(userQuery),
toolResponse
);
}
}
Інтеграція в AgentConversationRunner (Agent Chat)
// В AgentConversationRunner.ask() — додаємо grounding
private String ask(String systemPrompt, List history,
String lastMessage, AgentSender currentSender) {
List messages = new ArrayList<>();
messages.add(new SystemMessage(systemPrompt));
// ... history mapping як раніше ...
messages.add(new UserMessage(lastMessage));
ToolCallback[] tools = ToolCallbacks.from(
wikipediaSearchTool,
tavilySearchTool,
alphaVantageTool,
arxivSearchTool,
newsApiSearchTool
);
try {
ChatResponse response = agentChatModel.call(
new Prompt(messages,
ToolCallingChatOptions.builder()
.toolCallbacks(tools)
.build()));
String result = response.getResult().getOutput().getText();
// Видаляємо блоки якщо є (для qwen3)
return removeThinkingBlock(result);
} catch (IllegalStateException e) {
// Grounding fallback: tool call не вдався — відповідаємо без tools
log.warn("Tool call failed for agent {}: {}", currentSender, e.getMessage());
return agentChatModel.call(new Prompt(messages))
.getResult().getOutput().getText();
}
}
private String removeThinkingBlock(String text) {
if (text == null) return "";
// Видаляємо ... блоки які qwen3 додає
return text.replaceAll("(?s).*?", "").trim();
}
Тест grounding pipeline
@SpringBootTest
class GroundingPipelineTest {
@Autowired
private GroundingPipeline pipeline;
@Test
void shouldReturnNotFoundWhenDocumentMissing() {
// Запит про документ якого немає
String result = pipeline.processWithGrounding(
"умови договору з клієнтом XYZ-99999",
"test-tool-call-id"
);
// Відповідь має визнати що не знайшло — не вигадувати
assertThat(result)
.containsAnyOf("не знайшов", "не знайдено", "відсутня інформація")
.doesNotContain("$") // не вигадав ціну
.doesNotContain("30 днів"); // не вигадав умови
}
@Test
void shouldIncludeCitationInResponse() {
String result = pipeline.processWithGrounding(
"яка ціна на базовий план",
"test-tool-call-id"
);
// Відповідь має містити посилання на джерело
assertThat(result)
.containsAnyOf("Згідно з", "документ", "розділ");
}
}
Висновки
Grounding — це різниця між агентом якому можна довіряти і агентом
який виглядає розумним але може вигадати будь-що. Більшість туторіалів
показують як викликати tool. Ніхто не показує що робити після того
як tool відповів. Саме тут ховаються 90% проблем production агентів.
Особливо критично для систем де відповіді мають реальні наслідки —
юридичні документи, ціни, умови договорів, медичні дані.
Там одна невірна відповідь коштує дорожче ніж весь час витрачений
на впровадження grounding.
П'ять правил grounding — і як перевірити що вони працюють
1. Порожній результат ≠ «можна відповідати з пам'яті»
Передавайте is_error: true і явну текстову інструкцію
в контент tool result. Як перевірити: видаліть всі документи з бази —
агент має відповідати «не знайшов» на будь-який запит, а не вигадувати.
2. Векторний score недостатньо
Score 0.85 означає «найближчий документ» — не «відповідь на питання».
Confidence scoring дає семантичну перевірку яку score не дає.
Як перевірити: зробіть запит про конкретний клієнт якого немає в базі —
агент не має відповідати умовами іншого клієнта.
3. Re-query з жорстким лімітом
Максимум 2 спроби. NOT_RELEVANT — зупиняємось одразу без re-query.
Як перевірити: в логах після кожного пошуку має бути
attempt X/2 і причина зупинки.
4. Citation обов'язкова
Агент має знати звідки кожна відповідь і вміти це показати.
Як перевірити: запитайте агента «звідки ця інформація?» —
він має назвати конкретний документ і розділ, не «з бази знань».
5. Явні інструкції в tool result — не тільки в системному промпті
«Не відповідай з власних знань» в системному промпті —
це рекомендація. Та сама фраза в контенті tool result —
це інструкція яку модель бачить прямо перед відповіддю.
Як перевірити: тест з is_error: true — відповідь не має містити
жодних конкретних цифр або фактів.
Чеклист перед деплоєм агента в production
// Grounding checklist — перевірте кожен пункт перед деплоєм
□ Tool повертає явну інструкцію при порожньому результаті
(не порожній рядок, а "РЕЗУЛЬТАТ НЕ ЗНАЙДЕНО + ІНСТРУКЦІЯ")
□ Tool використовує is_error: true для notFound і technicalError
□ Є перевірка score з порогами:
score >= 0.75 → success()
score 0.55-0.75 → lowRelevance()
score < 0.55 → notFound()
□ Confidence scoring увімкнено для критичних типів запитів
(ціни, договори, дедлайни)
□ Re-query має MAX_ATTEMPTS = 2 і зупиняється на NOT_RELEVANT
□ Tool result містить citation: назву документу, розділ, дату
□ Логується stop_reason для кожної відповіді агента
□ Є тест: порожня база → агент відповідає "не знайшов"
(не вигадує відповідь)
□ Є тест: is_error: true → відповідь без конкретних цифр і фактів
Три типові помилки які знищують grounding
// ❌ Помилка 1: порожній рядок замість явної інструкції
return "";
// Модель інтерпретує по-різному — іноді відповідає з пам'яті
// ✅ Правильно:
return "РЕЗУЛЬТАТ НЕ ЗНАЙДЕНО. НЕ відповідай з власних знань.";
// ❌ Помилка 2: покладатись на системний промпт для grounding
new SystemMessage("Якщо не знаєш — скажи що не знаєш");
// Працює іноді. Недетерміновано. Не в production.
// ✅ Правильно:
// Явна інструкція в кожному tool result де потрібно
// ❌ Помилка 3: re-query без ліміту
while (!found) {
currentQuery = reformulate(currentQuery);
result = search(currentQuery);
}
// Нескінченний цикл якщо документа немає в базі
// ✅ Правильно:
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { ... }
Наступний крок у серії: grounding вирішує проблему
якості результатів. Але є інша проблема — коли у агента стає
10-15+ tools, передавати їх всі в кожен запит стає неефективно
і якість вибору tool падає. Як будувати динамічний реєстр інструментів
і підключати тільки потрібні tools до конкретного запиту —
→ Tool RAG — що робити коли у агента 100 інструментів.
Читайте також у серії про AI агентів:
→ Tool Use vs Function Calling — базова механіка перш ніж будувати grounding.
→ Як LLM вирішує коли викликати tool — як модель приймає рішення до того як отримати результат.
→ Agent Chat — живий приклад де grounding критичний для Wikipedia і Tavily tools.
Джерела: Spring AI Documentation, Anthropic Tool Use Docs, WildToolBench (2026)