гора.
AI-ассистент линии поддержки

Технический стек

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

~17 мин чтения · 3437 слов

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 через шлюз мессенджера и из чата на сайте через WebSocket сообщение попадает в бэкенд бота; там пре-гейт, тема по базе знаний, заказы из API платформы, память о клиенте, модель со строгой схемой и сторож с одной переделкой; исходы — ответить, промолчать, черновик людям, отдать человеку, передать водителю или изменить заказ

КомпонентЧто делаетТехнологии
Шлюз мессенджерапринимает сообщения 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 с другого домена, WebSocketJavaScript без фреймворка
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 тыс. строк вместе со всеми правилами. По порядку:

  1. Допуск. Текст или фото, главный рубильник, пустое сообщение, фото без байтов.
  2. Заявка в Redis и проверка повтора.
  3. Человек уже ответил после этого сообщения — исход «промолчать».
  4. Лимиты на номер клиента и на всю линию.
  5. Быстрые ветки до модели: номер заказа, который клиент назвал раньше; «да» или «нет» на предложенное изменение заказа; «спасибо» после вопроса, на который ещё не ответили.
  6. Пре-гейт pre_gate: 8 причин, по которым сообщение нельзя вести как обычное (жалоба, юридический вопрос, деловое предложение, просьба позвать человека и другие), плюс «ок» и эмодзи, ссылка без текста, вопрос об отмене. Жалобу, юридический вопрос и деловое предложение бот держит для людей. На просьбу позвать человека с 6 октября уходит заготовленный текст с телефоном офиса. Вопросы о работе, о сроках и об отмене идут дальше, к модели.
  7. Тема. Эмбеддинг сообщения, 5 ближайших соседей в базе знаний, решает большинство из трёх. Свою тему называет и модель, и правила смотрят на обе: например, о каком заказе вопрос, решает та из двух тем, которая вообще говорит о заказе.
  8. Заказы клиента через API платформы.
  9. Заготовленные ветки: как платить, «сейчас ничего не едет», передать водителю самому.
  10. Живые данные магазина: открыт ли он, доставляют ли по адресу, что на полке, товар по присланной ссылке.
  11. Дословный ответ из базы знаний, если сходство не ниже 0,80 и живых данных в вопросе нет.
  12. Сборка подсказки. Системная часть постоянна: стиль, факты магазина, правила, темы, 31 пункт правил линии и блоки шага 3, изменений заказа и поиска. Всего 51 708 символов. Всё живое идёт в сообщение пользователя: блоки заказов, данные магазина, ответ из базы, карточка клиента, результат поиска. Поэтому системный префикс одинаков у всех запросов с одним набором флагов, и работает префиксный кэш OpenAI. В среднем на запрос уходит 17,8 тыс. токенов подсказки и 162 токена ответа.
  13. Модель: GPT-5.2, температура 0,1, до 400 токенов, строгая JSON-схема. Поля: тема (одна из 24), ответ, уточнение, нужен ли человек, уверенность, причина. С шагом 3 добавляется передача водителю, с поиском — запрос поиска, с изменениями заказа — 9 полей изменения.
  14. Модель попросила человека — отдельные ветки: передать водителю, спросить номер заказа, заказ закрыт, жалоба опоздала, повторный вызов модели с пометкой «человека на линии нет».
  15. Сторож guard: 18 видов ошибок, ещё 3 о том, чего нельзя обещать без человека (собеседование, «вернёмся позже», «передадим»), и 1 на повтор того же ответа. Среди них time_promise, invented_time, tracking_link, external_url, unknown_phone, claims_person, unasked_cancel, too_long.
  16. Одна переделка. Модель получает свой ответ и текст причины из GUARD_REPAIRS, если на это осталось время. Переделанный ответ проходит сторожа целиком. Не прошёл — заготовленная строка (правило оплаты, «я автоматический помощник», статус заказа) или «держать с человеком».
  17. Исход в _finish. send ставится, только если волна этого класса открыта и нет остановок: человек писал в последние 15 минут (для классов шире статуса, оплаты и правил — в последние 60), залп сообщений, повтор того же шаблона подряд, незакрытая передача человеку, главный выключатель отправки.

За трое суток до снимка обработка в боте заняла 4,2 секунды в медиане и 7,5 в 95-м процентиле. Клиент ждёт дольше, около 29 секунд в медиане: 20 из них шлюз ждёт, не допишет ли клиент ещё.

Волны и рубильники

У каждого ответа, который бот отправляет сам, есть метка волны. Набор открытых волн собирается при старте из флагов окружения, поэтому смена флага — это пересоздание контейнера, около 2,4 секунды. Волны w0–w4 открыты всегда, остальные открывает каждая свой флаг.

ВолнаОткрытаЧто открыла
w1, w2a, w2b21.09приветствия; статус заказа и «где водитель» без ссылки и со ссылкой
w2c21.09тема не узнана, но в ответе ссылка на страницу этого заказа
w024.09ответ на явное «спасибо»
w324.09как платить, заготовленные тексты
w424.09«сейчас ничего не едет»
w528.09отправку решает сам ответ модели
w628.09тему называет модель, до трёх уточняющих вопросов
w728.09клиент сам пишет водителю, бот подсказывает как
w828.09номер не узнан — просим номер заказа
w928.09вопрос водителю или диспетчерам
w10, w1129.09всё, кроме списка «всегда человек»; поиск сведений о товаре
w1230.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. Они пишутся в журнал, но не в память, а все отчёты отбрасывают их одним условием.

Выкат

  1. Push в основную ветку запускает GitHub Actions.
  2. Тесты: компиляция, полный набор, импорт приложения, поиск секретов в дереве.
  3. Сборка образа с тегами коммита и latest в реестре GitHub. В собранном образе проверяется, что файлы базы знаний на месте и загружаются.
  4. Выкат: вход на сервер по SSH, docker pull, docker compose up -d.
  5. Проверка: внешний запрос к /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/ — документ правил.