Перейти к содержимому

Разрешить сайту считать ваши посещения с помощью Яндекс Метрики? Для этого она сохранит в браузере файлы cookie. Как сайт использует cookie

VC
Кейс 18 из 25 · Python · Данные

Автономные боты: как сделать их надёжными и честно проверять на истории

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

Отрасль
Финтех · собственная разработка
Технологии
Python · asyncio · WebSocket · DuckDB / SQLite
Формат
Свой инструмент для собственных задач
Итог
Тихие отказы стали видны
01 · Проблема

Автономная система ломается незаметно

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

Вот настоящие случаи из этого проекта. Первый: внешний API поменял одно слово в названии инструмента. Разбор данных перестал узнавать часть потока, и поломку заметили через 10 часов. Второй: соединение открыто, ошибок нет, события идут, но одного типа событий нет уже больше 20 минут. Общий сторожевой процесс следил за «последним событием вообще» и молчал, потому что остальные типы продолжали идти. Третий: сторожевой процесс часами писал restarts=0 failures=0, хотя два процесса были мертвы. Команда pgrep -f искала имя процесса по всей строке команды и находила его в соседних оболочках, включая собственную.

Четвёртый случай самый дорогой: система выдаёт неверные цифры и при этом выглядит исправной. Симулятор, который считал результаты на истории, определял исход сделки по неверному моменту фиксации. С реальным правилом расчёта он совпадал в 51% случаев, то есть на уровне подбрасывания монеты. Всё, что строилось поверх этого симулятора, месяцами выглядело как аналитика. На деле это был шум, и отличить одно от другого было нечем, пока не проверили само правило расчёта.

02 · Решение

Пять частей, и каждая проверяется отдельно

Система принятия решений в реальном времени состоит из пяти независимых частей: сбор данных, живучесть фоновых служб, наблюдение за состоянием, проверка идей на истории и защитный контур. Ломается каждая из них по отдельности, поэтому и проверять каждую нужно отдельно.

01
Сбор данных

Основной поток событий и страховочный опрос раз в 5 с. У каждой записи в базе есть источник, время и отметка о том, кто её загрузил

02
Фоновые службы

Свой пусковой скрипт на Python с двойным ветвлением процесса, systemd с автоперезапуском и общим журналом

03
Наблюдение

Сторожевой процесс на каждый тип события, проверка, не сменился ли формат данных у внешнего сервиса, сводка в мессенджер

04
Проверка на истории

Прогон по историческим записям: реальные движения цены, отсев повторов, разбивка по режимам рынка

05
Защитный контур

Многоуровневая проверка по расписанию, файл-метка полной остановки как аварийный выключатель, возврат в работу только через скрипт

Почему фоновые процессы на 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 мс; дальше упираемся в сеть, и ускорить можно только сменой архитектуры
03 · Технологии

Ничего экзотического, всё держится на дисциплине

Python 3 · asyncio

Приём потока событий, циклы принятия решений, сторожевые задачи рядом с рабочим кодом

WebSocket, запасной путь через REST

Основной путь — реакция на событие, опрос раз в 5 с только подбирает пропущенное

DuckDB / SQLite

Основное хранилище. У каждой записи есть источник, пишет один процесс, аналитика подключается только на чтение

systemd · Restart=always · journald

10 сервисов на одной машине, перезапуск без ручных действий, журналы в одном месте

launchd и пусковой скрипт с двойным ветвлением

Фоновые задачи на macOS, которые не умирают вместе с терминалом

Cron и таймеры

Сбор записей в хранилище, определение исходов сделок, регулярные сверки

Временный буфер JSONL (≤48 ч)

Только там, где нужен сырой поток; всё остальное лежит в базе

Регрессионные тесты на каждое исправление

Каждую ошибку закрывает тест, который бы её поймал. Без такого теста она может вернуться

Сводка в мессенджер

Состояние всех сервисов и «сколько минут назад» по каждому потоку событий

Журнал инвариантов (I-NNN)

Правила с датой доказательства, случаем, из которого они выросли, и условиями отмены

PythonasyncioWebSocketDuckDBSQLitesystemdjournaldlaunchdcrondouble-forkwatchdogdata_provenance
04 · Результат

Отказы видны, цифрам можно верить

Тихий отказ заметен через
10 ч 6 мин

порог по каждому типу событий и проверка на смену формата данных

Сервисов под надзором
10

на одной машине: Restart=always и journald, перезапуск без человека

Точность симулятора
51% 100%

совпадение с реальным правилом расчёта после исправления на всей проверенной выборке; выборка пока небольшая

Правила записаны в журнал инвариантов

Каждое найденное правило записывается как пронумерованный инвариант: дата, когда правило доказано, случай, из-за которого оно появилось, инструкция «как применять» и отдельным пунктом условия, при которых правило перестанет действовать. Каждое правило появляется после настоящего сбоя и содержит способ его воспроизвести. Поэтому одну и ту же ошибку не приходится заново открывать через полгода на новом сервисе.

Проверка на истории, которая не льстит

Проверять исправление на живом запуске значит ждать днями и всё равно остаться без статистики. Поэтому исправление прогоняется по историческим записям. У каждой закрытой записи сохранены реальные крайние точки движения цены, и результат «что было бы, если» считается по тому, как всё двигалось на самом деле. Методика записана как набор обязательных правил:

  • сначала простые метрики без допущений: распределения и доли достижения уровней. Если движения нет вообще, никакая настройка выходов не поможет
  • отсев повторов: один и тот же сигнал размножается по конфигурациям примерно в 12 раз, и без отсева выборка раздувается искусственно
  • разбивка по режиму рынка и типу сигнала: общее число скрывает, что система выигрывает в одном режиме и проигрывает в другом
  • границы для неоднозначных случаев: если порядок событий внутри свечи неизвестен, считаются оптимистичная и пессимистичная оценки, и вывод принимается, только если он одинаков в обеих
  • контроль случайным входом: та же логика выхода на случайных входах должна проигрывать. Если она выигрывает, «эффект» создаёт сама механика выхода
  • не меньше 30 случаев в каждой ячейке, проверка на данных за пределами периода подбора (out-of-sample и walk-forward), поправка на множественную проверку гипотез

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

Защитный контур останавливает систему сам

Многоуровневый монитор запускается по расписанию и проверяет несколько независимых условий остановки. Остановка оформляется файлом-меткой, который видят все процессы. Это простой аварийный выключатель, которому не нужна сеть. Дальше действует дисциплина: метки остановки руками не правят. Вернуть систему в работу может только штатный скрипт, и сначала он проверяет причину остановки. Для новых фильтров отдельное правило: сначала режим наблюдения, в котором фильтр только записывает своё решение в журнал. Право блокировать он получает, когда накопятся данные.

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

05 · Где пригодится

Где ещё работает тот же подход

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

  • → Интеграции с внешними API, где формат меняется без предупреждения: проверка формата срабатывает сразу, и разбирать инцидент задним числом не приходится
  • → Фоновые обработчики и очереди, где «процесс жив» ещё не значит «работа идёт»: признак жизни считается по каждому типу событий
  • → Машинное обучение и аналитика, где расчёты на истории расходятся с рабочей системой: сначала проверяется правило расчёта целевого показателя, потом сама модель
  • → Автономные ИИ-агенты, которые тратят деньги или совершают действия: им нужны тот же аварийный выключатель, режим наблюдения и лимиты
  • → Данные, которые будут проверять: записанное происхождение каждой строки помогает через полгода ответить, откуда взялась цифра в отчёте
  • → Телеметрия, интернет вещей, логистика: тот же поток событий, мало времени на реакцию и отказы без единого сообщения об ошибке
Что я переношу в следующие проекты
  • Пусковой скрипт с двойным ветвлением и готовые описания служб для systemd и launchd: фоновая задача действительно отцепляется от терминала
  • Сторожевой процесс на каждый тип событий, проверку формата данных у внешнего сервиса и отчёт о состоянии со временем последнего события
  • Хранение в SQL-базе: у каждой записи есть источник, соединение открывается только на время записи, чтение отделено от записи
  • Хранилище только на дозапись: данные собираются до любой очистки, при сбое сбора очистка отменяется, копия хранится отдельно
  • Методику проверки на истории: отсев повторов, разбивка по режимам, границы для неоднозначных случаев, контроль случайным входом
  • Формат журнала инвариантов: дата доказательства, случай, как применять, когда правило перестанет действовать
Похожая задача?

Если процесс работает без присмотра, важно одно: узнаете ли вы о его поломке

Начать стоит с двух замеров: через сколько минут вы узнаёте о тихом отказе и считает ли проверка на истории то же самое, что рабочая система. Часто уже эти два замера показывают первые слабые места, до того как они проявятся сами.