В углу сайта живёт морфер — пиксельный индикатор состояния. Сначала он просто дышал и тускнел от проблем. Потом я дал ему голос: клик открывает панель, где можно спросить про текущую страницу — «что тут есть?», «о чём статья?», «найди материал про 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, чтобы ловить деградацию при смене модели.