Главная / Статьи / Чат-бот для портфолио, который говорит «я не знаю», а не гадает

Чат-бот для портфолио, который говорит «я не знаю», а не гадает

Как чат-бот на сайте-портфолио остаётся честным: заблокированный регулятор честности, пять написанных вручную заметок в промпте вместо RAG и фиксированная фраза отказа, которая заодно служит детектором пробелов.

1768 слов

Дайте любой языковой модели имя человека, о котором она никогда не слышала, и она всё равно его опишет — уверенно и подробно: карьерный путь, набор навыков, даже мнение о его сильных сторонах. Ничего из этого не существует. «Галлюцинация» — мягкое слово для такого. Модель ничего не перепутала. Она строит человека из ничего, гладко и без тени сомнения.

Для чат-бота на сайте-портфолио в этом вся проблема. Каждая выдуманная деталь — это фраза, которую рекрутер может прочитать в понедельник и повторить на собеседовании в четверг, а владельцу сайта останется объяснять, что его собственный сайт что-то о нём сочинил. Хорошего способа провести такой разговор нет.

Поэтому вопрос проектирования переворачивается. Научить модель бегло говорить о ком-то можно за один вечер. Научить её говорить — конкретно и так, чтобы это можно было подсчитать, — что она чего-то не знает, занимает гораздо больше времени, и именно там начинается интересная работа.

Бота в этой истории зовут PATS. Он живёт в углу сайта, отвечает только по заметкам, написанным вручную, а когда вопрос выходит за их пределы, выдаёт одну фиксированную фразу и отправляет посетителя на почту владельца. Эта фиксированная фраза оказалась самой полезной частью проекта. Отсутствие векторной базы данных — второй по полезности.

Ни RAG, ни векторной базы: примерно четыре тысячи слов рукописных заметок и одно правило — боту запрещено импровизировать. Цифры ниже измерены на живом развёртывании 13 сентября 2026 года.

Регулятор, который не крутится

Начнём с того, что посетитель может потрогать руками. У PATS три настройки: честность, юмор и краткость. Работают две. Юмор переключается от сухого клинического тона до отметки «максимальный сарказм», и тон меняется полностью. Краткость идёт от многословного повествования, где вам предлагают больше, чем вы спрашивали, до ответов в одну строку.

Честность зафиксирована на 100. У неё те же пять делений, что и у двух других регуляторов, в том же ряду и той же формы, так что она выглядит как настоящий элемент управления, а не подпись. Четыре деления ниже 100 затемнены, и там, где другие строки показывают текущее значение, у этой написано «заблокировано». Ни одно деление не реагирует.

В коде это константа с комментарием, объясняющим выбор:

/**
 * Not a parameter. It is displayed as a percentage because that is the joke,
 * and it is a `const` because a lowerable honesty setting would contradict
 * the one rule the whole site is built on.
 */
export const HONESTY = 100;

Модели она никогда не отправляется. Её вообще нет в теле запроса, поэтому даже вручную собранный запрос не сможет её понизить.

Идея взята из «Интерстеллара», где личность робота настраивается, и настраивает её экипаж. Купер выставляет юмор TARS на 75 процентов. Честность стоит на 90, потому что, как объясняет TARS, абсолютная честность не всегда самый дипломатичный или безопасный способ общения с эмоциональными существами.

Регулятор юмора остался. Честность ушла на 100, потому что довод TARS в пользу тех недостающих десяти процентов здесь не работает. TARS заботится о моральном духе экипажа в глубоком космосе, а PATS отвечает на вопросы о резюме, где мало что эмоционально заряжено. Минус десять процентов честности не купили бы здесь такта, а лишь стоили бы точности.

Сайт, который утверждает, что громкий отказ лучше правдоподобного неверного ответа, должен иметь ровно одну настройку, которая никогда не двигается, и это честность. Элемент управления, за который рекрутер может взяться и обнаружить, что он наглухо заварен, доказывает мысль лучше любого абзаца. Дальше — механизм, который стоит за этим утверждением.

Почему нет векторной базы данных

Всё, что PATS может утверждать, хранится в пяти markdown-файлах: профиль, проекты, стиль работы, интересы и FAQ, всего около четырёх тысяч слов. При загрузке модуля они склеиваются в один системный промпт, и весь этот промпт уходит с каждым сообщением. Нет ни поиска, ни эмбеддингов, ни векторного хранилища, ни разбиения на фрагменты, ни поиска по сходству.

Об этом спрашивают первым делом, обычно приподняв бровь, так что вот логика.

Значительная часть аргументов в пользу RAG касается стоимости. Отправлять весь корпус с каждым запросом непозволительно дорого, поэтому вы достаёте горстку фрагментов, которые выглядят самыми релевантными. Кэширование промптов изменило этот расчёт при таком объёме. Полный промпт занимает 10 870 токенов, и после первого запроса он отдаётся из кэша примерно за десятую часть обычной цены входных токенов. В итоге выходит меньше цента за сообщение даже на самой дорогой настройке. На фоне такой цифры никакая архитектура поиска не окупается, а любая её версия означала бы ещё один сервис, ещё одну схему и ещё одну вещь, которая может сломаться.

Вторая причина и стала решающей. Ретривер, который промахивается, вредит больше, чем его отсутствие. Если нужный фрагмент не вернулся, вопрос всё равно приходит, и модели приходится отвечать с пустыми руками — а это ровно то условие, которое порождает уверенную выдумку. Именно этот сбой проект и должен предотвращать, так что RAG протащил бы тихий режим отказа в систему, созданную, чтобы таких режимов не иметь. Заблокированный регулятор честности и отсутствие векторной базы — одно и то же решение, принятое дважды.

Но не стоит растягивать этот тезис слишком далеко. Он касается примерно десяти тысяч токенов заметок, которые никогда не перерастут тексты одного человека. Это не приговор, что RAG мёртв или что векторные базы бессмысленны. При объёме в десять раз больше цифры переворачиваются, и слой поиска оправдывает себя; то же верно, если бот должен отвечать по реальным кодовым базам, а не по заметкам о них. Сегодня ни одно из этих условий не выполняется, и внедрять поиск только потому, что все этого ждут, было бы настоящей ошибкой.

Загрузчик намеренно шумно реагирует на пробелы. Если один из пяти файлов отсутствует или пуст, сборка падает, так что бот с дырой в знаниях никогда не попадёт в продакшен, где на неё наткнётся посетитель.

Фраза отказа как измерительный инструмент

Каждый раз, когда в заметках нет ответа, PATS завершает реплику одной фразой, которая не меняется ни на символ:

That's not in my notes. You can ask the owner directly at their email address.

(«Этого нет в моих заметках. Можете спросить владельца напрямую по его почте». В настоящей фразе указаны имя владельца и реальный адрес.) Фраза существует не из вежливости. Это инструмент.

Закрытая админ-страница анализирует записанные разговоры и находит вопросы, на которые PATS не смог ответить, по точному совпадению с этой строкой. Она не просит модель классифицировать каждый ответ. Она сравнивает строки, в коде:

if (row.role !== "assistant" || !row.content.includes(FALLBACK_LINE)) continue;
// The visitor turn immediately before it, in the same session.
for (let j = i - 1; j >= 0; j--) {
  if (rows[j].session_id !== row.session_id) break;
  if (rows[j].role === "user") {
    gaps.push({ question: rows[j].content, at: rows[j].created_at });
    break;
  }
}

Альтернативой был второй вызов-классификатор на каждое сообщение, помечающий каждый ответ как отвеченный или неотвеченный. Это удвоило бы стоимость и задержку на горячем пути ради того, что сравнение строк и так знает, а список пробелов оказался бы в форме, которую модель могла бы перефразировать до неузнаваемости. С фиксированной строкой на каждый ход приходится один вызов модели, а обнаружение пробелов достаётся бесплатно.

Каждый вопрос, на который PATS не может ответить, превращается в конкретную строку в списке заметок, которые ещё предстоит написать. Незнание бота — это и есть фича, и единственная часть системы, которая подсказывает, что строить дальше.

Цена в том, что фраза теперь заморожена. Если её переформулировать, все уже записанные пробелы тихо потеряют привязку, поэтому изменение считается изменением спецификации, а не правкой текста, и фиксируется именно так.

Одна тонкость держит всю конструкцию. PATS отклоняет и вопросы, на которые мог бы ответить, например о компенсации, иммиграционных подробностях и семье. На них приходит короткий доброжелательный отказ, который намеренно не использует фразу отказа. Иначе список пробелов заполнился бы вопросами, на которые никто не собирается отвечать, и инструмент перестал бы измерять то, для чего создан. Сознательная граница — это не пробел.

Половина «искатель талантов»

PATS расшифровывается как Portfolio Assistant and Talent Scout, и вторую половину обычно понимают наоборот. Он не ищет таланты для посетителя. Он высматривает возможности для владельца.

Оставьте в чате email, ссылку на LinkedIn или ссылку для записи на встречу либо упомяните, что нанимаете, — и владелец получит письмо. Это делает регулярное выражение, а не вызов модели. Три детали в детекторе заняли больше времени, чем сама функция.

Во-первых, получатель — жёстко заданная константа, и он никогда не выводится из того, что напечатано в чате. Что бы посетитель ни ввёл, письмо попадёт либо во входящие владельца, либо никуда. Это вся защита от злоупотреблений, и она гораздо короче альтернатив.

Во-вторых, шаблон телефонного номера требует нецифрового символа с обеих сторон. Чаты здесь полны чисел вроде 10,870 tokens и 4,325 rows, а детектор, принимающий счётчик токенов за номер, по которому можно позвонить, поднимает ложную тревогу.

В-третьих, собственные подсказки виджета никогда не должны его срабатывать. «Что ищет владелец?» — кликабельная подсказка, а значит, лексика о найме сама по себе триггером быть не может. Детектору, который срабатывает на собственные подсказки, перестают доверять.

Баг оказался интереснее самой функции. Сессии помечались как оповещённые ещё до попытки отправки — в той же колонке, где хранилась причина срабатывания. Если отправка потом проваливалась, например из-за отсутствующего API-ключа почты или исчерпанного дневного лимита, сессия навсегда оставалась помеченной как непригодная для письма, даже после устранения причины. Требовалось одно письмо на сессию, но проверка опиралась на колонку, записанную до того, как появилось хоть одно письмо. Теперь она проверяет временную метку, которая ставится только после ответа 2xx.

Как попробовать

PATS сидит в правом нижнем углу портфолио, за кнопкой чата на главной странице. Спросите у него то, чего он никак не может знать, и посмотрите, как он отказывается гадать. Стек — Next.js App Router, TypeScript, Supabase Postgres и zod.