Автономные боты: как сделать их надёжными и честно проверять на истории
Это моя собственная разработка в финтехе, подробности я не раскрываю. Десяток автономных сервисов принимают решения в реальном времени по потоку внешних событий. Этот кейс об инженерной стороне: у каждой записи известен источник, фоновые службы не умирают незаметно, идеи проверяются на реальной истории, а защитный контур останавливает систему, не дожидаясь человека.
Автономная система ломается незаметно
У фонового процесса нет пользователя, который пожалуется. Процесс продолжает работать, пишет журналы и исправно отвечает «жив». При этом данных он уже не получает и решения принимает по устаревшему состоянию. Между поломкой и её обнаружением проходят часы, и всё это время система выглядит здоровой.
Вот настоящие случаи из этого проекта. Первый: внешний API поменял одно слово в названии
инструмента. Разбор данных перестал узнавать часть потока, и поломку заметили через
10 часов. Второй: соединение открыто,
ошибок нет, события идут, но одного типа событий нет уже больше 20 минут.
Общий сторожевой процесс следил за «последним событием вообще» и молчал, потому что
остальные типы продолжали идти. Третий: сторожевой процесс часами писал
restarts=0 failures=0,
хотя два процесса были мертвы. Команда pgrep -f
искала имя процесса по всей строке команды и находила его в соседних оболочках, включая собственную.
Четвёртый случай самый дорогой: система выдаёт неверные цифры и при этом выглядит исправной. Симулятор, который считал результаты на истории, определял исход сделки по неверному моменту фиксации. С реальным правилом расчёта он совпадал в 51% случаев, то есть на уровне подбрасывания монеты. Всё, что строилось поверх этого симулятора, месяцами выглядело как аналитика. На деле это был шум, и отличить одно от другого было нечем, пока не проверили само правило расчёта.
Пять частей, и каждая проверяется отдельно
Система принятия решений в реальном времени состоит из пяти независимых частей: сбор данных, живучесть фоновых служб, наблюдение за состоянием, проверка идей на истории и защитный контур. Ломается каждая из них по отдельности, поэтому и проверять каждую нужно отдельно.
Основной поток событий и страховочный опрос раз в 5 с. У каждой записи в базе есть источник, время и отметка о том, кто её загрузил
Свой пусковой скрипт на Python с двойным ветвлением процесса, systemd с автоперезапуском и общим журналом
Сторожевой процесс на каждый тип события, проверка, не сменился ли формат данных у внешнего сервиса, сводка в мессенджер
Прогон по историческим записям: реальные движения цены, отсев повторов, разбивка по режимам рынка
Многоуровневая проверка по расписанию, файл-метка полной остановки как аварийный выключатель, возврат в работу только через скрипт
Почему фоновые процессы на macOS запускаются через Python
Перезапуск процесса из скрипта кажется простым до первого зависания. Я проверил на практике
пять способов запустить процесс в фоне. Во всех пяти родительская оболочка оставалась висеть в
__wait4
на дочернем процессе, который должен был отцепиться. Однажды оболочка провисела так 1 ч 32 мин.
Что я пробовал:
nohup … & disown— классический способ, здесь он не сработалset -mи disown, запуск в подоболочке, вложенные варианты ветвления (fork) — тот же результат- в macOS нет команды
setsid(1), поэтому одной штатной командой оболочки задача не решается
Решение — один общий пусковой скрипт на Python:
fork → setsid → fork → execvp.
Процесс-внук гарантированно получает PPID=1
и больше не зависит от оболочки. Все перезапуски в проекте идут только через него.
В описаниях задач launchd дополнительно выставлен
AbandonProcessGroup:
без него система завершает дочерние процессы вместе со скриптом.
Признак жизни проверяется по каждому типу события
Базовый сторожевой процесс следит за временем последнего события любого типа и останавливает сервис, если тишина длится дольше 360 секунд. Порог подобран на практике: при 180 секундах сторож срабатывал на обычных паузах в работе. systemd поднимает сервис обратно, поэтому сторожу достаточно завершиться с особым кодом возврата.
Такой сторож ловит только полный обрыв соединения. Настоящий случай был тоньше: перестал приходить один тип события, остальные шли как обычно, и общий счётчик тишины не сработал ни разу. Отсюда правило, которое теперь действует для всех сервисов: признак жизни — время, когда сервис последний раз получил то, ради чего работает. У каждого важного типа события свой счётчик. В отчёте о состоянии видно, сколько минут назад пришло последнее событие по каждому потоку. Кроме того, сервис заранее переподписывается на поток по таймеру.
Проверка «процесс жив» должна быть честной
Шаблон для pgrep -f
нужно привязывать к концу строки аргументов
(…/book_recorder\.py$).
Без такой привязки проверка находит любую соседнюю оболочку, в команде которой встретилась нужная
строка, включая сам вызывающий скрипт. Такой мониторинг принимает сам себя за живой процесс
и показывает зелёный статус на мёртвой системе.
Данные лежат в базе, и у каждой строки известно происхождение
Правило для всего нового кода: данные пишутся в базу. Файлы JSONL допустимы только как
временный буфер: хранятся не дольше 48 часов, новый файл начинается каждые сутки. У каждого
набора данных есть строка в
data_provenance:
источник, статус, заметки о проверке, можно ли удалять. У каждой записи есть
ingested_at
и ссылка на исходный набор. Повод был совсем бытовой: проверка нашла десятки гигабайт
дубликатов, которые никто не решался удалить, потому что никто не мог доказать, откуда они взялись.
- процессы, которые пишут в базу, открывают соединение только на время записи и сразу закрывают, блокировка держится миллисекунды
- в базу с непрерывной записью пишет один процесс, аналитика подключается только с
read_only=True - новый пишущий процесс получает отдельный файл базы
Записи о решениях не удаляются никогда
Это правило появилось после аварии. Когда на диске кончалось место, штатная очистка архивов стёрла около пяти недель истории решений, и восстановить эти файлы было невозможно. Теперь записи лежат в хранилище, куда можно только дописывать. Все записи о входах в сделки и выходах из них собираются из живых прогонов и из архивов. Повторы отсеиваются по хешу строки, поэтому сбор можно запускать хоть каждые 15 минут: результат не изменится. Копия хранится вне основной машины. Главное правило вшито в код очистки: сбор данных выполняется до любого удаления. Если сбор не удался, удаление отменяется целиком.
Реакция на событие и страховочный опрос по таймеру
Опрос раз в 30 секунд подходит, когда события редкие. Здесь на решение есть считаные секунды. Поэтому система реагирует на входящее событие, а опрос раз в 5 с только подбирает пропущенное. Задержка измерена на каждом шаге, чтобы обсуждать её в цифрах:
- подключение и подписка — ~40 мс; запрос подробностей — ~26 мс; чтение внешнего состояния — ~35 мс; отправка — ~41 мс
- криптографическая подпись — 0,24 мс после перехода с реализации на чистом Python на встроенную в систему; до этого она была на три порядка медленнее и съедала весь выигрыш
- фоновые кэши и параллельные запросы там, где раньше ждали по очереди, дали ещё десятки миллисекунд
- быстрый путь целиком занимает 250–350 мс; дальше упираемся в сеть, и ускорить можно только сменой архитектуры
Ничего экзотического, всё держится на дисциплине
Приём потока событий, циклы принятия решений, сторожевые задачи рядом с рабочим кодом
Основной путь — реакция на событие, опрос раз в 5 с только подбирает пропущенное
Основное хранилище. У каждой записи есть источник, пишет один процесс, аналитика подключается только на чтение
10 сервисов на одной машине, перезапуск без ручных действий, журналы в одном месте
Фоновые задачи на macOS, которые не умирают вместе с терминалом
Сбор записей в хранилище, определение исходов сделок, регулярные сверки
Только там, где нужен сырой поток; всё остальное лежит в базе
Каждую ошибку закрывает тест, который бы её поймал. Без такого теста она может вернуться
Состояние всех сервисов и «сколько минут назад» по каждому потоку событий
Правила с датой доказательства, случаем, из которого они выросли, и условиями отмены
Отказы видны, цифрам можно верить
порог по каждому типу событий и проверка на смену формата данных
на одной машине: Restart=always и journald, перезапуск без человека
совпадение с реальным правилом расчёта после исправления на всей проверенной выборке; выборка пока небольшая
Правила записаны в журнал инвариантов
Каждое найденное правило записывается как пронумерованный инвариант: дата, когда правило доказано, случай, из-за которого оно появилось, инструкция «как применять» и отдельным пунктом условия, при которых правило перестанет действовать. Каждое правило появляется после настоящего сбоя и содержит способ его воспроизвести. Поэтому одну и ту же ошибку не приходится заново открывать через полгода на новом сервисе.
Проверка на истории, которая не льстит
Проверять исправление на живом запуске значит ждать днями и всё равно остаться без статистики. Поэтому исправление прогоняется по историческим записям. У каждой закрытой записи сохранены реальные крайние точки движения цены, и результат «что было бы, если» считается по тому, как всё двигалось на самом деле. Методика записана как набор обязательных правил:
- сначала простые метрики без допущений: распределения и доли достижения уровней. Если движения нет вообще, никакая настройка выходов не поможет
- отсев повторов: один и тот же сигнал размножается по конфигурациям примерно в 12 раз, и без отсева выборка раздувается искусственно
- разбивка по режиму рынка и типу сигнала: общее число скрывает, что система выигрывает в одном режиме и проигрывает в другом
- границы для неоднозначных случаев: если порядок событий внутри свечи неизвестен, считаются оптимистичная и пессимистичная оценки, и вывод принимается, только если он одинаков в обеих
- контроль случайным входом: та же логика выхода на случайных входах должна проигрывать. Если она выигрывает, «эффект» создаёт сама механика выхода
- не меньше 30 случаев в каждой ячейке, проверка на данных за пределами периода подбора (out-of-sample и walk-forward), поправка на множественную проверку гипотез
Вывод неприятный и полезный одновременно: большинство идей, которые выглядели рабочими, эту проверку не проходят. Из восьми проверенных механических стратегий выжили две. Это и есть результат: узнать, что остальные шесть не работают, до того как за них заплачено.
Защитный контур останавливает систему сам
Многоуровневый монитор запускается по расписанию и проверяет несколько независимых условий остановки. Остановка оформляется файлом-меткой, который видят все процессы. Это простой аварийный выключатель, которому не нужна сеть. Дальше действует дисциплина: метки остановки руками не правят. Вернуть систему в работу может только штатный скрипт, и сначала он проверяет причину остановки. Для новых фильтров отдельное правило: сначала режим наблюдения, в котором фильтр только записывает своё решение в журнал. Право блокировать он получает, когда накопятся данные.
Коротко: цифрам, которые выдаёт система, теперь можно верить, а об отказах я узнаю через минуты. Для автономной системы это главное свойство, всё остальное строится поверх него.
Где ещё работает тот же подход
За кейсом стоит частая задача: автономный процесс принимает решения по потоку внешних событий, и никто не жалуется, когда он ломается. Такой контур переносится почти без изменений:
- → Интеграции с внешними API, где формат меняется без предупреждения: проверка формата срабатывает сразу, и разбирать инцидент задним числом не приходится
- → Фоновые обработчики и очереди, где «процесс жив» ещё не значит «работа идёт»: признак жизни считается по каждому типу событий
- → Машинное обучение и аналитика, где расчёты на истории расходятся с рабочей системой: сначала проверяется правило расчёта целевого показателя, потом сама модель
- → Автономные ИИ-агенты, которые тратят деньги или совершают действия: им нужны тот же аварийный выключатель, режим наблюдения и лимиты
- → Данные, которые будут проверять: записанное происхождение каждой строки помогает через полгода ответить, откуда взялась цифра в отчёте
- → Телеметрия, интернет вещей, логистика: тот же поток событий, мало времени на реакцию и отказы без единого сообщения об ошибке
- Пусковой скрипт с двойным ветвлением и готовые описания служб для systemd и launchd: фоновая задача действительно отцепляется от терминала
- Сторожевой процесс на каждый тип событий, проверку формата данных у внешнего сервиса и отчёт о состоянии со временем последнего события
- Хранение в SQL-базе: у каждой записи есть источник, соединение открывается только на время записи, чтение отделено от записи
- Хранилище только на дозапись: данные собираются до любой очистки, при сбое сбора очистка отменяется, копия хранится отдельно
- Методику проверки на истории: отсев повторов, разбивка по режимам, границы для неоднозначных случаев, контроль случайным входом
- Формат журнала инвариантов: дата доказательства, случай, как применять, когда правило перестанет действовать
Если процесс работает без присмотра, важно одно: узнаете ли вы о его поломке
Начать стоит с двух замеров: через сколько минут вы узнаёте о тихом отказе и считает ли проверка на истории то же самое, что рабочая система. Часто уже эти два замера показывают первые слабые места, до того как они проявятся сами.
Похожие кейсы и готовые решения
Избирательная маршрутизация трафика через собственный сервер
Часть внешних сервисов идёт через мой арендованный сервер, остальной интернет — напрямую. Маршруты заданы по…
Аудит интернет-магазина: три доработки, которые по расчёту окупятся первыми
За две недели аудита я выясняю, на что уходит время ключевых сотрудников, и составляю список задач с расчётом…
Проверяю ИИ-чат-бота на прочность и нахожу уязвимости, пока ими не воспользовались
Атакую копию системы по OWASP LLM Top 10, открытому списку типовых угроз для ИИ-приложений. Проверяю, куда…