В углу сайта живёт морфер — пиксельный индикатор состояния. Сначала он просто дышал и тускнел от проблем. Потом я дал ему голос: клик открывает панель, где можно спросить про текущую страницу — «что тут есть?», «о чём статья?», «найди материал про Grafana». Ответ пишется в чат, ссылки кликабельны, а на «да, открой» морфер сам переходит на статью.
Самое интересное здесь не фронтенд, а экономика: я сознательно взял самую дешёвую модель и компенсировал её слабые места гардрейлами. Этот пост — про то, почему так можно и что именно приходится проверять.
От YandexGPT к AiTunnel
Первый чат был на YandexGPT — он же писал авто-теги и суточный дайджест. Схема работала, но зависела от одного вендора и его формата. Когда появилась задача «чат про страницу», я перевёл общий LLM-хелпер на AiTunnel — это OpenAI-совместимый агрегатор: один ключ, api.aitunnel.ru/v1, оплата в рублях, VPN не нужен. В коде поменялся только base URL и имя модели.
# было: POST https://llm.api.cloud.yandex.net/... (modelUri gpt://.../yandexgpt-lite)
# стало: POST https://api.aitunnel.ru/v1/chat/completions
payload = {
"model": "mistral-nemo",
"messages": [...],
"max_tokens": 1024,
"temperature": 0.1,
"provider": {"sort": "price"}, # взять самого дешёвого провайдера
}
Поле provider: {sort: price} — важная деталь. У AiTunnel одну модель крутят несколько провайдеров, и сортировка «по цене» отключает балансировку по загрузке в пользу самого дешёвого. Для публичного сайта это то, что нужно.
Почему самая дешёвая модель
Я прошёл путь deepseek-v4.1-flash → mistral-nemo. По каталогу mistral-nemo стоит 3.8 ₽ за 1M токенов на входе и 6 ₽ на выходе — примерно в десять раз дешевле по выводу. Реальный ответ в чате выходит 0.01–0.08 ₽. За сотни вопросов в день это копейки.
Но дешёвая модель — это характер. Она уверенно перефразирует заголовки, переводит запросы, иногда выдаёт «Кака медель» вместо реального названия. Весь фокус в том, чтобы разделить ответственность: пусть модель делает то, что делает хорошо (понять вопрос, собрать связный ответ), а корректность держат детерминированные проверки на сервере и клиенте.
Что умеет чат
- Контекст страницы. Не сырой
innerText, а структура:meta description+
заголовки h1–h3 + текст <main> (до 30 000 символов), без nav/footer.
- История. Хранится в
sessionStorage, переживает переходы между страницами;
на сервер уходят последние реплики.
- Стриминг. Ответ приходит потоком (SSE), текст печатается по токенам.
- Инструменты (tool calling). Модель может вызвать
search_blog(поиск по
статьям), get_glossary (глоссарий), get_site_status (свежий дайджест) — и ответить фактами сайта, а не пересказом страницы.
- Кэш, лимиты, происхождение. Кэш ответов, проверка
Origin/Referer,
лимит по IP — эндпоинт публичный и стоит денег.
- Телеметрия.
cost_rub, токены, латентность, использованные инструменты —
в лог и в Loki (job=ai_chat).
Пять гардрейлов
Это ядро идеи. Каждый появился после конкретного провала.
1. Ссылки: реальные URL и дословные заголовки
Первая же проблема: модель красиво пересказывала названия и ссылки. Гардрейл проверяет каждую ссылку на наш домен по списку реальных страниц (посты + статические маршруты + /app/*) и сверяет текст ссылки с дословным заголовком из выдачи инструмента. Не совпало — ответ подменяется точным списком из инструмента.
corrected = _link_guardian(answer, tool_outputs, page["known_urls"])
if corrected is not None:
yield {"type": "replace", "text": corrected}
2. Орфография и качество текста
Иногда модель выдаёт мусор: «Кака медель», «состтояния», «бис был и бастр». Проверять словарь бесполезно — он не знает «деплой» и «мониторинг». Поэтому я сравниваю ответ с словарём того, что реально давали модели: контекст страницы, выдача инструментов, корпус блога и русский словарь (с лёгким стеммингом окончаний). Если доля «неизвестных» слов выше порога (AICHAT_SPELL_MAX, по умолчанию 0.30) — ответ не отдаём, показываем «ИИ временно недоступен» и шлём алерт в ntfy.
Нормальный технический текст даёт ~0.05–0.17 «неизвестных», мусор — 0.3–0.6. Порог разводит их уверенно.
3. Повтор — детерминированно
На «повтори последний запрос» модель вместо повтора начинала эхо-повторять вопрос. Теперь повтор обрабатывается на клиенте: берём последний ответ ассистента из истории и выводим дословно, без модели и сети. Опечатки («повторип оследний») тоже ловятся.
4. Запрет выдуманных статей
На «самая сложная статья?» модель сочинила «Аудит Архитектуры» — статьи, которой нет. Гардрейл: если инструмент search_blog нашёл статьи, а в ответе нет ни одной ссылки на них — доверяем инструменту и показываем проверенный список. Плюс правило в промпте: субъективные рейтинги не выдумывать, а честно говорить, что ранжировать нечем.
5. Семантическое «да/нет»
Морфер предлагает: «Хотите прочитать эту статью?» Если пользователь отвечает коротко («да», «открой», «вторую») — понятно. А если развёрнуто — «ну наверняка мне было бы интересно, я думаю»? Тогда уходит запрос на /api/ai/intent, который в JSON-режиме классифицирует намерение как open | skip | question и возвращает короткий вердикт. Морфер показывает его в чате и, если «да», открывает страницу:
пользователь: ну наверняка мне было бы интересно я думаю
морфер: Скорее да — открываю страницу → /blog/morfer-zhivoy-indikator
Интересно, что дешёвая модель на этой фразе колебалась, пока в промпт не добавились few-shot примеры и JSON-режим. После этого — 5 из 5 стабильно «open».
Кейсы, которые чему-то научили
- «Найди интересные статьи» → «статей не найдено». Модель перевела запрос на
английский, а поиск был буквальной подстрокой. Починил: токенизация, стоп-слова, ранжирование по заголовку/тегам/тексту, фолбэк на свежие статьи.
- Ссылки вели на 404. Инструмент строил
/<slug>/, а посты живут на
/blog/<slug>. Теперь и выдача, и список «реальных» URL используют канонический формат.
- «Кака медель». Галлюцинация в заголовках при верных ссылках — отсюда
орфографический гардрейл и запрет переписывать названия.
- «Аудит Архитектуры». Выдуманная статья без ссылки — отсюда правило
«нет ссылки на найденное → показываем список инструмента».
Экономика и границы
Схема даёт удивительно много за копейки: mistral-nemo + provider: {sort: price} + пять гардрейлов. Телеметрия в Loki показывает, что типичный ответ стоит 0.01–0.08 ₽.
Но честно про границы: гардрейлы ловят форму, а не истинность. Ответ может быть грамматически идеальным, с правильными ссылками — и всё равно неточным по сути. Поэтому основа — это grounding: контекст страницы и данные инструментов; модель отвечает по ним, а не по памяти.
Итого принцип: чем дешевле модель, тем больше веса у инженерных проверок и данных, на которые она опирается. Модель можно менять одной переменной окружения — гардрейлы остаются.
Что дальше
- Дашборд
ai_chatв Grafana: стоимость по дням, топ вопросов, доля с
инструментами.
- Роутинг «сложный вопрос → модель сильнее» (или
model: "auto"). - Кнопки фидбэка 👍/👎 и «объясни проще».
- Мини-эвал из типовых вопросов в CI, чтобы ловить деградацию при смене модели.
Загрузка…