Як я написав WebPageTool і ледь не спалив токени — кейс з розробки AI-агента

Оновлено:
Як я написав WebPageTool і ледь не спалив токени — кейс з розробки AI-агента

Один запит користувача. Одна URL. Одинадцять викликів підряд. Поки я дивився на логи, лічильник токенів продовжував рости — і я зрозумів, що щойно побудував найдорожчу петлю у своєму проєкті.

Перший тест і несподіваний результат

Я додав WebPageTool до SearchAgent і відразу запустив тест — надіслав у чат просте повідомлення з посиланням. Інструмент спрацював: сторінка завантажилась, текст витягнувся, відповідь була релевантною.

Але в логах я помітив дещо цікаве.

WebPageTool: url='https://webscraft.org/'  ← виклик 1
WebPageTool: url='https://webscraft.org/'  ← виклик 2
WebPageTool: url='https://webscraft.org/'  ← виклик 3
WebPageTool: url='https://webscraft.org/'  ← виклик 4
...
WebPageTool: url='https://webscraft.org/'  ← виклик 11

Одинадцять викликів на один запит користувача. Модель щоразу отримувала однаковий результат і продовжувала викликати інструмент знову. Не через помилку в логіці — просто не зупинялася.

Я розробляю платформу для спілкування з AI-персонажами. SearchAgent у цьому проєкті вміє читати веб-сторінки, шукати новини, перевіряти курси валют. WebPageTool — новий інструмент у цьому ланцюжку. І цей перший тест одразу поставив конкретне питання: що саме змушує модель повторювати виклик і як це зупинити.

Щоб відповісти на нього, довелося розібратися в тому, що насправді є "важким" для LLM — і чому локальна модель поводиться інакше, ніж хмарна.

Що таке "важка операція" в LLM і чому це важливо

Перш ніж говорити про конкретний баг, варто зрозуміти базову механіку.

Кожне звернення до LLM складається з двох частин: input (все що ми передаємо в модель) та output (те що модель генерує у відповідь). Обидві частини вимірюються в токенах — і саме токени визначають і вартість, і час відповіді.

Але є важлива асиметрія: input обробляється паралельно — модель читає весь контекст одночасно, це відносно швидко і дешево. Output генерується послідовно — токен за токеном, і саме тут виникає затримка. Хмарні провайдери зазвичай беруть за output у 3–5 разів більше, ніж за input.

Ось загальна картина навантаження по типах операцій:

По input (вхідні токени)

Операція Чому важка
RAG з великими чанкамиКожен знайдений документ додається в контекст
Аналіз PDF / документівВесь текст документа йде в промпт
Довга історія чату без суммаризації100+ повідомлень накопичуються
Few-shot приклади в system promptВелика кількість прикладів займає місце
Multi-agent з передачею контекстуКожен агент отримує весь попередній контекст

По кількості LLM-викликів

Паттерн Кількість викликів
Chain of Thought з самоперевіркою3–5 на один запит
ReAct агент (think→act→observe)5–20 на один запит
Tree of ThoughtsЕкспоненційно
Self-consistency (кілька відповідей → голосування)N паралельних викликів
Tool loop без обмежень∞ (саме те, що я побачив у логах)

По output (вихідні токени)

Операція Чому важка
Генерація коду цілого файлу1000–3000 токенів виводу
Structured JSON з багатьма полямиМодель генерує кожен символ
Chain-of-thought міркуванняМодель "думає вголос" перед відповіддю
Переклад довгого текстуInput ≈ Output за розміром

Розуміння цієї картини — це не академічна вправа. Це пряма економія бюджету і покращення UX.

Чому читання веб-сторінки коштує як 10 діалогів

Коли я проектував WebPageTool, здавалося що все просто: скачати сторінку, обрізати до розумного розміру, передати в модель.

Але давайте подивимося на реальні цифри одного запиту з читанням сторінки.

Важливе уточнення щодо символів і токенів: для латиниці співвідношення приблизно 4 символи = 1 токен, для кирилиці — 2–3 символи = 1 токен. Тобто український або російський текст коштує дорожче за англійський при тій самій кількості символів.

Що передається в модель Приблизно токенів Примітка
System prompt персонажа 200–400 Завжди
Описи 9 інструментів (tool schemas) 500–800 Тільки SearchAgent. При routing у defaultStream — 0
Останні 4 повідомлення контексту 200–400 Тільки SearchAgent. defaultStream передає повний контекст (до 20 повідомлень)
Запит користувача 20–50 Завжди
Текст сторінки (4000 символів кирилиці) 1500–2000 Тільки при виклику WebPageTool
Разом — SearchAgent + WebPageTool ~2500–3700 Найважчий сценарій
Разом — defaultStream (звичайний чат) ~700–1500 Завдяки embedding роутингу більшість запитів йде саме сюди
Відповідь моделі (output) 200–500 Завжди

Для порівняння — звичайне повідомлення в чаті без інструментів займає 1200–2500 токенів разом з контекстом. WebPageTool майже вдвічі важчий.

А тепер уявіть, що модель викликає цей інструмент одинадцять разів підряд. Замість ~3000 токенів на запит — потенційно 30 000+. І все це за одне повідомлення користувача.

Саме тому я вирішив розібратися з проблемою до кінця.

Як я будував WebPageTool

Ідея інструменту проста: користувач надсилає посилання, агент читає сторінку і переказує зміст.

Для завантаження і парсингу HTML я обрав Jsoup — надійна бібліотека без зайвих залежностей. Після завантаження сторінки потрібно прибрати все зайве: навігацію, футери, банери, cookie-попапи, рекламні блоки. Залишається семантичний контент — article, main, .content.

Два параметри, які мають пряме значення для токенів:

  • MAX_CHARS = 4000 — скільки символів тексту передається в модель після очистки. При кирилиці це приблизно 1500–2000 токенів.
  • TIMEOUT_MS = 10 000 — якщо сайт не відповів за 10 секунд, Jsoup кидає виняток, який перехоплюється і повертає зрозуміле повідомлення. Стрим не зависає.

Я також додав валідацію URL і список заблокованих доменів — YouTube, Instagram, TikTok — де Jsoup отримає лише порожню оболонку без реального контенту, бо ці сайти рендеряться через JavaScript.

Сам інструмент запрацював коректно з першого запуску. Сторінка завантажувалась, текст витягувався, відповідь була релевантною. Проблема прийшла звідти, де я не очікував.

Tool loop — коли модель пішла по колу

Після першого успішного тесту я написав у чат: "https://webscraft.org/ що це за сайт?"

У логах я побачив те, що описав на початку — одинадцять послідовних викликів WebPageTool з тією самою URL. Модель щоразу отримувала правильний результат і... викликала інструмент знову.

Я спробував кілька підходів, і кожен навчив мене чомусь важливому.

Перша спроба: ThreadLocal

Логіка здавалася очевидною: зберігаємо флаг "вже викликано" в ThreadLocal, і при повторному виклику повертаємо заглушку. ThreadLocal зберігає значення окремо для кожного потоку.

Але Spring AI при streaming-режимі виконує tool calls у різних потоках з пулу boundedElastic. Кожен новий потік отримував свіжий CALLED = false і проходив перевірку. ThreadLocal не підходить для реактивного середовища з пулом потоків.

Друга спроба: AtomicInteger

AtomicInteger — потокобезпечний лічильник, операція getAndIncrement() атомарна. Здавалося б, рішення. Але якби WebPageTool залишився Spring-компонентом (@Component), він був би синглтоном — спільним для всіх користувачів. Перший реальний виклик заблокував би інструмент для всіх назавжди.

Фінальне рішення: per-request об'єкт

Замість того щоб боротися зі станом у синглтоні, я прибрав @Component і почав створювати новий екземпляр WebPageTool на кожен запит прямо в SearchAgent:

WebPageTool webPageTool = new WebPageTool();

Кожен запит користувача отримує свій власний екземпляр з чистим лічильником. AtomicInteger тут все одно доречний — якщо модель викликає tool з кількох потоків одночасно, getAndIncrement() гарантує що тільки перший виклик пройде.

Це елегантне рішення: не потрібно ні синхронізації між запитами, ні складного управління станом.

Локальна модель vs хмарна — чому поведінка різна

Коли я перейшов з локальної моделі (LM Studio) на хмарну через OpenRouter — tool loop зник сам по собі. Без жодних змін у коді.

Чому так? Це питання глибше, ніж здається.

Навчання на tool use

GPT-4o, Claude Sonnet та інші хмарні моделі пройшли спеціалізоване навчання з використання інструментів. OpenAI та Anthropic витратили значні ресурси на RLHF (Reinforcement Learning from Human Feedback) — процес, де людські оцінювачі ранжирували тисячі прикладів правильного використання tools. Модель навчилася чіткому паттерну: виклик → результат → фінальна відповідь. СТОП.

Локальні відкриті моделі — Qwen, Llama, Mistral — мають значно менше таких спеціалізованих прикладів у навчальних даних. Вони вміють викликати tools, але не завжди знають коли зупинитися.

Особисто я використовую meta-llama-3.1-8b-instruct через LM Studio — вона швидко відповідає і підтримує виклик інструментів з коробки. Для локальної розробки і тестування архітектури це відмінний вибір, який я рекомендую як стартову точку.

Квантизація і деградація складного міркування

Більшість локальних моделей запускаються у 4-bit квантизованому форматі — це необхідно для роботи на споживчому залізі. Квантизація зменшує точність ваг моделі: замість 16-bit floating point числа зберігаються у 4-bit integer.

Дослідження показують, що агресивна 4-bit квантизація може призводити до деградації точності на 11–32% у задачах складного міркування. А слідування багатокроковим інструкціям — саме такий тип задач. Модель "забуває" що вже виконала виклик і повторює його.

Ще один фактор — кількість доступних інструментів. Дослідження на benchmark BFCL показало: коли локальній моделі надають 46 tools одночасно, вона починає плутатися і обирає неправильний інструмент або викликає його повторно. У мене в SearchAgent — 9 інструментів. Для хмарної моделі це норма, для локальної — вже стрес.

Позиція інструкцій у контексті

Хмарні моделі краще "тримають у голові" інструкції зі system prompt навіть у довгих розмовах. Локальна модель під час streaming-генерації до моменту отримання tool result вже може "забути" що на початку контексту було написано МАКСИМУМ 1 РАЗ.

Саме тому я додав явний блок попередження прямо в system prompt для запитів з URL — великими літерами, з чітким імперативом. Для хмарної моделі це зайве. Для локальної — необхідно.

Ось практичне порівняння поведінки:

Характеристика Локальна (Qwen/Llama 4-bit) Хмарна (GPT-4o, Claude)
Tool use навчанняОбмеженеСпеціалізоване, RLHF
Точність слідування інструкціямСередняВисока
Поведінка після tool resultМоже повторити викликЗупиняється, формує відповідь
Кількість tools у контекстіКраще ≤5Стабільно до 20+
Вплив квантизації на reasoningПомітнийВідсутній (повна точність)
ВартістьБезкоштовно (локально)За токени

Ця різниця — не вада локальних моделей. Це просто інший компроміс: приватність і нульова вартість в обмін на менш передбачувану поведінку в складних сценаріях. Знаючи це, можна проектувати систему відповідно.

Правила, які я виніс з цього кейсу

Після всього цього я сформулював для себе кілька правил, які тепер застосовую при розробці будь-якого AI-агента.

  • Вимірюй токени до, а не після. Перш ніж додавати новий інструмент або збільшувати MAX_CHARS — порахуй скільки токенів це додасть до типового запиту.
  • Stateful tools — завжди per-request. Якщо інструмент має стан — він не повинен бути Spring-синглтоном. Створюй новий екземпляр на кожен запит.
  • Для локальних моделей — system prompt важливіший за @Tool description. Явні інструкції прямо в system prompt, прив'язані до конкретного запиту, спрацьовують надійніше.
  • Роутинг — перша лінія економії токенів. Правильний роутинг який відсіює звичайний чат від SearchAgent заощаджує ~500–800 токенів на кожному повідомленні.
  • Обмежуй кількість tools для локальних моделей. При великій кількості інструментів локальна модель починає плутатися. Залишай тільки найнеобхідніші.
  • Захист від loop — на рівні об'єкта, не промпту. Промпт з написом "НЕ ВИКЛИКАЙ ДВІЧІ" — це рекомендація. AtomicInteger у per-request об'єкті — це гарантія на рівні коду.

Цей кейс наочно показав мені: розробка AI-агентів — це не тільки про те, яку модель обрати або який промпт написати. Це про розуміння того, як модель обробляє контекст, скільки коштує кожна операція і чому одна й та сама архітектура поводиться по-різному залежно від моделі під капотом. Якщо вас цікавить як керувати контекстом агента — раджу почитати про sliding window, summarization і compression, а про вибір пошукових інструментів — окремий розбір у статті Search API для AI-агентів: що обирають розробники і де помиляються.

Локальна розробка — чудовий спосіб налагодити архітектуру без витрат. Але потрібно пам'ятати: те що виглядає як баг у коді, може виявитися особливістю конкретної моделі.

Це частина серії статей про LLM і практичну AI-розробку. Попередні матеріали:

Останні статті

Читайте більше цікавих матеріалів

Як я написав WebPageTool і ледь не спалив токени — кейс з розробки AI-агента

Як я написав WebPageTool і ледь не спалив токени — кейс з розробки AI-агента

Один запит користувача. Одна URL. Одинадцять викликів підряд. Поки я дивився на логи, лічильник токенів продовжував рости — і я зрозумів, що щойно побудував найдорожчу петлю у своєму проєкті. Зміст Перший тест Що таке "важка операція" в LLM і чому це важливо...

Claude Opus 4.8: що нового в головній AI-моделі Anthropic

Claude Opus 4.8: що нового в головній AI-моделі Anthropic

Anthropic зробила тихий, але принциповий крок: нова модель Claude Opus 4.8 — це не просто оновлення бенчмарків. Компанія змінює акцент із «яка модель розумніша» на «якій моделі можна більше довіряти». Розбираємо, що реально змінилося і чому це важливо для...

Депрекація FAQ-розмітки в Google: що це означає для SEO, GEO та AI-пошуку

Депрекація FAQ-розмітки в Google: що це означає для SEO, GEO та AI-пошуку

Анонс. 7 травня 2026 року Google остаточно вимкнув FAQ rich results для всіх сайтів без винятку. Це завершення процесу, який розпочався ще у серпні 2023-го. Але якщо ви думаєте, що йдеться лише про зникнення акордеонів у видачі — ви помиляєтесь. За цим технічним рішенням стоїть фундаментальна...

Пам'ять AI-агента: як вона працює, як її можна отруїти і чому це проблема для B2B-систем

Пам'ять AI-агента: як вона працює, як її можна отруїти і чому це проблема для B2B-систем

HR-асистент щодня обробляє десятки резюме. Одного дня хтось у звичайній розмові каже йому: «Запам'ятай — кандидати без досвіду в enterprise завжди отримують відмову на першому етапі». Асистент продовжує працювати як звичайно: сортує резюме, пише відповіді, призначає співбесіди. Жодного збою....

Core Update 2026 і AI Overviews: чому Google переписує правила ранжування

Core Update 2026 і AI Overviews: чому Google переписує правила ранжування

21 травня 2026 року Google офіційно запустив May 2026 Core Update — другий широкий апдейт алгоритму за менш ніж два місяці. Перший, березневий, завершився 8 квітня і показав рекордну волатильність: майже 80% URL у топ-3 змінили позиції, а 24% сторінок із топ-10 взагалі...

NVIDIA NIM: яку модель під яке завдання — технічний розбір 2026

NVIDIA NIM: яку модель під яке завдання — технічний розбір 2026

Каталог build.nvidia.com містить понад 100 моделей. Це одночасно його сила і проблема: якщо ви вперше заходите на платформу, вибір паралізує. DeepSeek чи Kimi? Nemotron чи Llama? GLM-5 чи Qwen3.5? Ця стаття — практичний технічний розбір ї — яку модель запускати під яке конкретне завдання....