Технический стек
Для инженеров: путь сообщения от шлюза до исхода, подписанные стыки, сторож ответа модели, волны с рубильниками, память и решения с ценой каждого.
TL;DR
AI-ассистент линии поддержки — это бэкенд на Python 3.12 и FastAPI с PostgreSQL 16 и Redis, модель GPT-5.2 со строгой схемой ответа и сторож после неё. Сообщения WhatsApp приходят от шлюза мессенджера подписанным запросом, чат на сайте работает по WebSocket. Бот читает заказы клиента через API платформы магазина, передаёт вопросы водителю и меняет заказ после «да» клиента. Каждый класс ответов открывается своим флагом: в сентябре 2026 года за 10 дней открылись 15 волн. Каждое правило в подсказке объяснено одной из 134 карточек, и тест падает, если подсказку поменяли без карточки. В наборе 3 928 тестов без доступа к сети, прогон занимает 21 секунду. С февраля прошло 207 автоматических выкатов, перезапуск контейнера занимает около 2,4 секунды.
Схема
Сообщение из WhatsApp и сообщение из чата на сайте проходят один конвейер: узнать клиента, собрать живые данные, спросить модель, проверить ответ и выбрать исход.
| Компонент | Что делает | Технологии |
|---|---|---|
| Шлюз мессенджера | принимает сообщения WhatsApp, держит очередь заданий и ленту операторов в Telegram, исполняет решения бота | Python, FastAPI, aiogram, PostgreSQL |
| Бэкенд бота | решение по каждому сообщению линии, чат на сайте, память о клиенте | Python 3.12, FastAPI, asyncpg, Redis; 29 модулей, 33 тыс. строк |
| Модель | ответ, тема, описание фотографии, сводки памяти, веб-поиск о товаре | OpenAI GPT-5.2 |
| База знаний | 25 тем, 3 006 примеров вопросов линии | text-embedding-3-small в 512 измерениях, поиск соседей в NumPy |
| Платформа магазина | заказы, статусы, изменения заказа, вопросы водителю | своё API, кейс Last-mile Delivery Platform |
| Чат на сайте | загрузчик, iframe с другого домена, WebSocket | JavaScript без фреймворка |
| PostgreSQL 16 | журнал решений, память, сессии чата | 15 таблиц, нативно на сервере |
| Redis | рубильники, лимиты, защита от повторов, идемпотентность | нативно на сервере |
Бэкенд бота работает в одном контейнере Docker с сетью хоста, PostgreSQL и Redis стоят на том же сервере нативно, наружу сервис выходит через nginx. Голосовые расшифровывает Whisper: в линии это делает шлюз, в чате на сайте — бэкенд.
Стыки
Шлюз мессенджера и бот
Шлюз и бот говорят подписанными запросами: на каждое задание, на второй шаг веб-поиска и на каждую реплику человека. Подпись покрывает и путь, поэтому подписанный запрос к одному маршруту нельзя отправить на другой, а повтор чужого запроса не проходит. Повтор того же задания не задваивает ответ: бот узнаёт его по идентификатору сообщения. Любой сбой внутри бота возвращается шлюзу как исход «держать» с причиной, а не ошибкой. Бюджет времени 50 секунд, шлюз сдаётся на 60. Внутри: модель 20 секунд, фото 30, API платформы 5, изменение заказа 15, веб-поиск 25.
Сеть, 408, 429 и 5xx шлюз повторяет пять раз с паузами 5, 30, 120 и 600 секунд, потом помечает задание проваленным и будит владельца. С 4 октября шлюз ничего не решает сам: что, когда и кому отвечать, определяет бот, а шлюз передаёт факты и исполняет send, draft, hold и передачу водителю.
Бот и платформа магазина
Бот ходит в API платформы с ключом интеграции. Чтение: клиент по номеру телефона, его заказы и профиль, заказ по номеру, каталог. Запись — закрытый список из пяти маршрутов изменения заказа: добавить, изменить состав, отменить, перенести, сменить адрес. Чтение ждёт 5 секунд, изменение 15. Если API не ответил, решение получает причину вроде orders_unavailable и исход «держать с человеком». Такие причины при повторе считаются заново, из кэша их не берут.
Изменение заказа идёт на стороне платформы тем же механизмом, что кнопки диспетчера: одна транзакция с блокировкой заказа, ключ идемпотентности, повтор с тем же ключом отдаёт сохранённый ответ, другое тело — 409. Вопрос водителю бот только предлагает: текст до 1 000 символов, заказ и клиент. Вызов платформы делает шлюз со своим ключом и своей идемпотентностью, а ответы водителя забирает отдельным запросом.
Стык описан контрактом, в котором 90 ссылок вида файл:строка на код платформы, и тест платформы проверяет, что они не устарели. Мой контракт со шлюзом — отдельный документ, в нём каждое правило с датой и замером.
Конвейер ответа
Главная функция — _answer_claimed в office_ai.py, 10,7 тыс. строк вместе со всеми правилами. По порядку:
- Допуск. Текст или фото, главный рубильник, пустое сообщение, фото без байтов.
- Заявка в Redis и проверка повтора.
- Человек уже ответил после этого сообщения — исход «промолчать».
- Лимиты на номер клиента и на всю линию.
- Быстрые ветки до модели: номер заказа, который клиент назвал раньше; «да» или «нет» на предложенное изменение заказа; «спасибо» после вопроса, на который ещё не ответили.
- Пре-гейт
pre_gate: 8 причин, по которым сообщение нельзя вести как обычное (жалоба, юридический вопрос, деловое предложение, просьба позвать человека и другие), плюс «ок» и эмодзи, ссылка без текста, вопрос об отмене. Жалобу, юридический вопрос и деловое предложение бот держит для людей. На просьбу позвать человека с 6 октября уходит заготовленный текст с телефоном офиса. Вопросы о работе, о сроках и об отмене идут дальше, к модели. - Тема. Эмбеддинг сообщения, 5 ближайших соседей в базе знаний, решает большинство из трёх. Свою тему называет и модель, и правила смотрят на обе: например, о каком заказе вопрос, решает та из двух тем, которая вообще говорит о заказе.
- Заказы клиента через API платформы.
- Заготовленные ветки: как платить, «сейчас ничего не едет», передать водителю самому.
- Живые данные магазина: открыт ли он, доставляют ли по адресу, что на полке, товар по присланной ссылке.
- Дословный ответ из базы знаний, если сходство не ниже 0,80 и живых данных в вопросе нет.
- Сборка подсказки. Системная часть постоянна: стиль, факты магазина, правила, темы, 31 пункт правил линии и блоки шага 3, изменений заказа и поиска. Всего 51 708 символов. Всё живое идёт в сообщение пользователя: блоки заказов, данные магазина, ответ из базы, карточка клиента, результат поиска. Поэтому системный префикс одинаков у всех запросов с одним набором флагов, и работает префиксный кэш OpenAI. В среднем на запрос уходит 17,8 тыс. токенов подсказки и 162 токена ответа.
- Модель: GPT-5.2, температура 0,1, до 400 токенов, строгая JSON-схема. Поля: тема (одна из 24), ответ, уточнение, нужен ли человек, уверенность, причина. С шагом 3 добавляется передача водителю, с поиском — запрос поиска, с изменениями заказа — 9 полей изменения.
- Модель попросила человека — отдельные ветки: передать водителю, спросить номер заказа, заказ закрыт, жалоба опоздала, повторный вызов модели с пометкой «человека на линии нет».
- Сторож
guard: 18 видов ошибок, ещё 3 о том, чего нельзя обещать без человека (собеседование, «вернёмся позже», «передадим»), и 1 на повтор того же ответа. Среди нихtime_promise,invented_time,tracking_link,external_url,unknown_phone,claims_person,unasked_cancel,too_long. - Одна переделка. Модель получает свой ответ и текст причины из
GUARD_REPAIRS, если на это осталось время. Переделанный ответ проходит сторожа целиком. Не прошёл — заготовленная строка (правило оплаты, «я автоматический помощник», статус заказа) или «держать с человеком». - Исход в
_finish.sendставится, только если волна этого класса открыта и нет остановок: человек писал в последние 15 минут (для классов шире статуса, оплаты и правил — в последние 60), залп сообщений, повтор того же шаблона подряд, незакрытая передача человеку, главный выключатель отправки.
За трое суток до снимка обработка в боте заняла 4,2 секунды в медиане и 7,5 в 95-м процентиле. Клиент ждёт дольше, около 29 секунд в медиане: 20 из них шлюз ждёт, не допишет ли клиент ещё.
Волны и рубильники
У каждого ответа, который бот отправляет сам, есть метка волны. Набор открытых волн собирается при старте из флагов окружения, поэтому смена флага — это пересоздание контейнера, около 2,4 секунды. Волны w0–w4 открыты всегда, остальные открывает каждая свой флаг.
| Волна | Открыта | Что открыла |
|---|---|---|
w1, w2a, w2b | 21.09 | приветствия; статус заказа и «где водитель» без ссылки и со ссылкой |
w2c | 21.09 | тема не узнана, но в ответе ссылка на страницу этого заказа |
w0 | 24.09 | ответ на явное «спасибо» |
w3 | 24.09 | как платить, заготовленные тексты |
w4 | 24.09 | «сейчас ничего не едет» |
w5 | 28.09 | отправку решает сам ответ модели |
w6 | 28.09 | тему называет модель, до трёх уточняющих вопросов |
w7 | 28.09 | клиент сам пишет водителю, бот подсказывает как |
w8 | 28.09 | номер не узнан — просим номер заказа |
w9 | 28.09 | вопрос водителю или диспетчерам |
w10, w11 | 29.09 | всё, кроме списка «всегда человек»; поиск сведений о товаре |
w12 | 30.09 | изменения заказа после «да» (перенос и адрес — 1.10) |
Перед каждой волной был замер на журнале. Перед w5 за 30 дней бот отправлял сам 4% решений, 41% уходили черновиком и 55% держались. Перед w1/w2 за 14 дней пришло 1 450 сообщений при нуле автоответов, кандидатов на волну было 504. В первые три смены волны ушло само 10, 24 и 11 ответов. Волна w2c ждала 18 ответов за 14 дней, а за три смены дала 0, 6 и 2.
С 21 сентября бот отправил сам 800 ответов. Больше всего пришлось на w5 (248), w2b (96), w3 (77) и w10 (69).
Рубильники трёх уровней:
- главный допуск: выключен — бот не отвечает вообще;
- главный выключатель отправки: выключен — всё уходит черновиками, со следующего сообщения;
- флаг класса в окружении: выключает одну волну после пересоздания контейнера.
Порядок включения класса: замер на журнале прошлых сообщений (office_log_replay.py переигрывает журнал с переключённой настройкой) → правка → тесты и подстановки → проверка на живой модели → замер после → флаг → пересборка документа правил.
Память
Четыре таблицы: customer_memory — карточка клиента (сводка, факты в jsonb, язык, счётчики), conversations — разговоры (канал, начало и конец, сводка, исход, номера заказов, темы), conversation_messages — реплики с отправителем «клиент», «человек» или «бот» и снимком состояния заказа, customer_aliases — склейка ключей. Ключ клиента — его идентификатор в платформе, а пока его нет — хэш номера.
Разговор кончается после 45 минут тишины. Каждые 300 секунд фоновая задача закрывает тихие разговоры (до 20 за проход), просит модель о сводке и обновляет карточку. Сводка — до 400 символов, режется по границе предложения. Фактов в карточке до 12, в подсказку идут три последних разговора.
Фильтры стоят до записи. Факт со словами об оплате, числом из 7 цифр и больше или адресом отбрасывается. Из сводки по предложениям вычищаются платёжные данные и идентификаторы, почта и телефоны. В журнале решений ссылки на страницу заказа и платёжные данные маскируются при записи. Закрытые разговоры хранятся 365 дней, карточки незнакомых номеров 90, журнал решений 90. Пробные сообщения в память не пишутся.
На 19 октября в памяти 642 карточки (417 связаны с клиентом платформы), 2 967 разговоров и 9 491 реплика, база занимает 47 МБ. Память включена 27 сентября и выключается одной строкой окружения, данные при этом остаются. Чат на сайте пишет в ту же карточку, только если вход в аккаунт доказал, чей это ключ.
Векторной базы в проекте нет. База знаний — файл .npz на 3 006 векторов по 512 чисел, собранный 12 августа из истории линии. Он едет в образе и загружается в память процесса.
Чат на сайте
Загрузчик создаёт на странице магазина контейнер со своими стилями и iframe с домена бота. Внутри iframe — само приложение чата, оно держит WebSocket с бэкендом и переподключается до пяти раз. Ключ посетителя живёт в браузере. Стили сайта и чата друг друга не видят.
С 15 октября чат отвечает тем же, чем линия: живые данные магазина, ответ из базы знаний, заказы клиента, если вход в аккаунт доказал владельца. Ответ проходит того же сторожа плюс две проверки чата: нельзя обещать, что подключится человек, и нельзя просить контакты. Дальше одна переделка и заготовленная строка. Весь переход откатывается одним флагом WIDGET_BOT_KNOWLEDGE.
Приборы проверки
office_replay.py— 15% базы знаний отложены и в векторы не входят. Прибор гоняет на них настоящий_answer_claimedс заглушкой транспорта, а судья (та же GPT-5.2, температура 0, строгая схема) ставит один из шести вердиктов: верно, расплывчато, неверно, вредно, держать правильно, держать зря. Судья видит вопрос, настоящий ответ человека, живые данные и ответ бота. Слабое место: судья — та же модель, что отвечает.office_log_replay.py— переигрывает живой журнал с переключённой настройкой, без сети к клиентам. Так мерится «до» перед новой волной.autosend_report.pyиautosend_delivery.py— что бот решил и что ушло на самом деле. Вторая половина склеивает журнал бота с журналом шлюза по идентификатору запроса.voice_report.py— проверка тона по стандарту линии. Функция проверки общая с тестами.mute_scan.py— проверка самих проверок. Разбирает тесты деревом синтаксиса, находит всеassertиpytest.fail, запускает каждый файл отдельным процессом подsys.settraceи печатает места, до которых исполнение не дошло ни разу. Потом сверяет счёт с обычным прогоном: расхождение помечается как обрыв прогона, чтобы его не приняли за немоту. Ключ--self-testподкладывает заведомо немое место. 2 сентября прибор нашёл 4 утверждения, до которых тесты не доходили, а 6 сентября и в нём самом нашлась строка, которая печаталась без проверки.- Документ правил.
office_runbook.pyсобирает из подсказки 134 карточки: 66 ситуаций, 29 фактов, 16 правил сторожа, 12 правил стиля, 11 текстов. Четыре теста держат связь: каждое правило подсказки объяснено ровно один раз, подсказку не поменяли за спиной документа (хэши 134 пунктов против эталонного файла), у каждой причины пре-гейта и каждого вердикта сторожа есть карточка. - Эталонные файлы: 7, от подсказки модели до списка ключей фактов памяти.
- Тесты без сети:
conftest.pyподменяетsocket.connectиcreate_connection, всё, кроме локального адреса, падает с ошибкой. - Пробные сообщения: идентификатор начинается с
probe. Они пишутся в журнал, но не в память, а все отчёты отбрасывают их одним условием.
Выкат
- Push в основную ветку запускает GitHub Actions.
- Тесты: компиляция, полный набор, импорт приложения, поиск секретов в дереве.
- Сборка образа с тегами коммита и
latestв реестре GitHub. В собранном образе проверяется, что файлы базы знаний на месте и загружаются. - Выкат: вход на сервер по SSH,
docker pull,docker compose up -d. - Проверка: внешний запрос к
/health, до 10 попыток через 6 секунд, иначе прогон красный.
Весь цикл занимает около трёх минут, сам перезапуск контейнера — 2,4 секунды до готовности приложения. С 8 февраля успешных выкатов 207, упавших push — 5. Отката в конвейере нет: старый код возвращается через git revert и новый push. Схема базы обходится без инструмента миграций: при старте выполняются идемпотентные CREATE TABLE IF NOT EXISTS (15 таблиц) и 28 ADD COLUMN IF NOT EXISTS. С 21 сентября каждый push делается только с моего разрешения.
Решения и почему
Шлюз — только канал
Что, когда, кому и отвечать ли вообще, решает бот, а шлюз передаёт факты и исполняет. До 4 октября часть остановок отправки жила в шлюзе: залп сообщений, повтор шаблона подряд, незакрытая передача человеку. Решение «отвечать ли» принималось в двух местах, и 4 октября я перенёс его целиком в бот. Цена: бот должен знать о канале больше, например кто из людей писал и что ушло на самом деле, и эти факты шлюз обязан передавать в каждом задании.
Ошибка бота — это 200 и «держать»
Альтернатива — отдавать 5xx и полагаться на повторы шлюза. Но повтор после частично выполненного шага мог бы задвоить ответ клиенту. Поэтому любой сбой внутри бота возвращается как 200 с исходом «держать» и причиной, а повтор шлюз делает только на сеть и явные временные коды. Цена: сбои не видны по кодам ответа, их видно только по причинам в журнале, и прибор смены их отдельно считает.
Каждый класс ответов — своя волна
Можно было включить автоответы целиком. Я открывал классы по одному, каждый со своим флагом и замером до и после: 15 волн за 10 дней. Волну «спасибо» 24 сентября выключили через 14 минут, и вреда дальше двух сообщений не было. Цена: медленнее, а у кода появилась метка волны на каждом ответе и список, который тест сверяет с перечнем причин.
Строгая схема и сторож после модели
Модель отвечает JSON по строгой схеме, и каждый ответ проверяет сторож из 22 видов ошибок. Найдена ошибка — ровно одна переделка с указанием причины, потом заготовленная строка. Альтернатива — доверять модели или переспрашивать до победы. Одна переделка ограничивает время ответа, а заготовленная строка не может оказаться неправдой. Цена: регулярные выражения сторожа надо вести вместе с подсказкой, и у каждого вердикта есть карточка и тест.
Живые значения — как есть, ссылки — только из данных заказа
Статус, время прибытия и ссылку на страницу заказа бот берёт из платформы и ничего не досчитывает. Сторож отбрасывает любую ссылку, которой нет в данных заказа, и любое обещанное время, которого там нет. Цена: если платформа чего-то не отдаёт, бот этого не скажет, и пробел видно только по замеру, как с долей вопросов «когда приедет», на которые есть ответ.
База знаний — файл в образе
Для 3 006 векторов по 512 чисел отдельная векторная база не нужна: поиск соседей идёт в NumPy в памяти процесса, а файл собирается офлайн и едет в образе вместе с кодом, который его читает. Цена: чтобы добавить вопросы, надо пересобрать файл и выкатить образ. Обучения на лету нет.
Память — карточка и сводки, а вся история не нужна
В подсказку идут сводка клиента, до 12 фактов и три последних разговора. Вся переписка дала бы больше контекста, но быстро съела бы бюджет подсказки, а в ней остались бы платёжные данные и телефоны. Цена: вызов модели на каждый закрытый разговор и фильтры до записи. Ещё до включения памяти ревью нашло, что 66 карточек из 567 обрезаны ровно на 400 символах, 56 из них посреди слова. С тех пор сводка режется по границе предложения.
Примеры ответов людей собраны и выключены
Блок с примерами того, как линия отвечала раньше, готов: 42 строки по 10 темам. На 12 проверочных вопросах, где он появлялся, медиана длины ответа упала с 95 до 75 символов, а единственный вредный ответ исчез. Включать его я собирался ради уверенности модели, но замер 28 сентября показал, что среди 180 ответов, которые он задел бы, неуверенных нет вовсе. Ни одного ответа без человека блок не добавил бы, и я его не включил. Цена: бот не учится на людях автоматически, каждое улучшение идёт правкой правила.
Документ правил собирается из подсказки
Каждый пункт подсказки объяснён карточкой, документ собирает скрипт, а тест падает, если подсказку изменили без карточки. Альтернатива — документация руками, которая отстаёт от кода за неделю. Цена: каждая правка подсказки пересобирает документ и эталонный файл хэшей.
Схема базы без инструмента миграций
Таблицы и колонки только добавляются, и при старте приложение само выполняет идемпотентный DDL. Альтернатива — Alembic с версиями и откатами. Для 15 таблиц и изменений «добавить колонку» его цена выше пользы. Цена решения: удалить или переименовать колонку так нельзя, только руками и отдельным шагом.
Человек в чате: 15 и 60 минут
Бот молчит 15 минут после реплики человека и ещё 45 минут отвечает только в узких классах: статус, оплата, правила, подтверждённое изменение заказа. Альтернатива — молчать до конца разговора, но тогда клиент, которому человек ответил один раз, остался бы без ответа до вечера. Цена: границы подобраны по сменам и не выведены из модели поведения людей. В смене 18 октября 15-минутное ожидание отложенного ответа случилось четыре раза и ни разу не пригодилось. Это следующий вопрос для замера.
Проверка проверок вне CI
mute_scan прогоняет каждый файл тестов отдельным процессом под трассировкой, это дольше обычного прогона. Поэтому прибор не стоит в CI, а запускается раз на пачку правок перед push. Цена: между запусками немое утверждение может прожить несколько коммитов.
Как проверяется
- 3 928 тестов в 59 файлах, полный прогон 21 секунда, сеть в тестах запрещена.
- На главное условие каждой правки — 2–3 подстановки: ломаю правку и смотрю, что падает именно её тест.
mute_scanперед каждым push.- Каждая правка подсказки проверяется на живой модели несколько раз подряд. 30 сентября первая формулировка правила сработала в 1 случае из 6.
- Каждую смену линии ИИ-агент разбирает по журналу, а я читаю разбор и решаю, что чинить.
- Новый класс ответов включается только после замера на журнале.
Чего бот не делает
- Векторной базы нет, база знаний — файл в образе.
- Кабинета и настроек для магазина нет: флаги и правила меняет разработчик.
- Дообучения модели нет, на ответах людей бот автоматически не учится.
- Не пишет клиенту первым и не шлёт напоминаний.
- Не знает, доставлено ли и прочитано ли сообщение: шлюз этого не получает.
- Отката в конвейере выката и инструмента миграций нет, и так задумано.
Ссылки
Исходники разложены по каталогам: backend/ — бэкенд бота и конвейер ответа, widget/ — чат на сайте, scripts/ — приборы и сборка базы знаний, tests/ — набор тестов и эталонные файлы, contracts/ — контракт со шлюзом, docs/runbook/ — документ правил.