Граница production: что обязано быть у LLM-бота до первого сообщения в живой чат. Часть 5

Пост опубликован в блогах iXBT.com, его автор не имеет отношения к редакции iXBT.com
| Статья | ИИ, сервисы и приложения

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

Речь о «Джонни» — язвительном инженерном персонаже в Telegram, который начинался как развлечение на вечер и дорос до системы с маршрутизацией, границами контекста и регрессионными проверками. Дальше — авторский разбор одного слоя моего бота. Код-примеры написаны с нуля для иллюстрации и кодом бота не являются; версии патчей и параметры приводятся по журналу проекта и независимо не проверялись — отношусь к ним как к авторским данным. Всё, что помечено как вывод демо, вычисляется кодом из статьи и воспроизводится запуском.

Инцидент, с которого всё началось

Однажды бот перестал нормально отвечать. Сервис числился запущенным, логи писались, ошибок в них не было — а в чате тишина вперемешку с невнятным поведением. Диагноз оказался унизительно простым: процессов бота было два. Один запустил systemd, второй остался жить в screen после ручной отладки. Оба держали одну и ту же сессию Telethon — а это SQLite-файл, — и база сессии ушла в состояние locked. Каждый процесс по отдельности был здоров. Больна была конфигурация, в которой их двое.

Показательно не то, что это случилось, а то, что ни один тест этого не ловил и не мог поймать. Юнит-тесты проверяют функции. Регрессионный набор проверяет маршрутизацию. CLI-прогон проверяет ответы. Никто не проверяет утверждение «в системе ровно один экземпляр процесса» — оно лежит вне кода, в среде исполнения.

Этот инцидент вместе с подготовкой к автономным репликам (бот получал право писать в чат сам, без входящего сообщения — тема отдельного разбора) привёл к аудиту всего, что касается побочных эффектов. Дальше — что аудит нашёл и как это закрывалось. В журнале проекта эта серия патчей называется P0, production boundary.

Семь находок

Аудит шёл не по коду, а по вопросу: «что произойдёт в живом чате, если это сломается?» Список получился таким:

  1. Служебные маркеры хранились внутри истории диалога. Строки вида [IMAGE_SENT], пути к временным файлам, метаданные model= лежали в той же структуре, что и реплики людей.
  2. Отправка в чат шла без последнего фильтра. Между «текст готов» и send_message не стояло ничего.
  3. Блокирующий HTTP жил внутри async-хендлера. Генерация изображения на десятки секунд останавливала обработку всего остального.
  4. Ничто не мешало запустить два экземпляра бота. См. инцидент выше.
  5. Не было идемпотентности входящих. Повторная доставка одного сообщения означала повторную обработку и второй ответ.
  6. SQLite работала без WAL. Конкурентная запись из нескольких задач упиралась в блокировки.
  7. Ошибки фоновых задач исчезали без следа. Задача падала — и никто об этом не узнавал.

Семь находок одного аудита и патчи, которыми они закрыты

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

Служебное — в служебные структуры

Самая незаметная находка — маркеры в истории. Появились они естественно: боту нужно помнить, что картинка по такому-то запросу уже отправлена, и проще всего было положить строку [IMAGE_SENT] прямо в историю диалога, рядом с репликами. Работало. До тех пор, пока не сложились два обстоятельства.

Первое: история диалога — это вход промпта. Всё, что в ней лежит, модель видит как часть разговора. Рано или поздно она начинает эти маркеры обсуждать — цитировать [IMAGE_SENT] в ответе, реагировать на путь /tmp/… как на реплику собеседника. Служебная строка, попавшая в промпт, — это уже наполовину служебная строка, попавшая в чат.

Второе хуже: на этих маркерах держался учёт. Сколько автономных реплик бот уже отправил за окно — вычислялось парсингом строк истории. Учёт лимитов, то есть механизм безопасности, зависел от того, что никто не переформатирует и не подчистит текстовое поле, которое вообще-то предназначено для разговора.

Патч P0A развёл эти миры: история диалога хранит только диалог; метаданные картинок уехали в таблицу image_events, учёт автономных отправок — позже в semantic_live_events. Правило из журнала проекта сформулировано жёстко: служебные данные запрещено хранить как часть диалога. Список запрещённого перечислен явно — маркеры отправки, пути /tmp, model=, bytes=, dry-run JSON, traceback. Место всему этому — в bot. log и технических таблицах.

Последний фильтр перед отправкой

Вторая находка — отсутствие фильтра на выходе — закрывается компонентом, который в журнале называется public-send guard. Идея примитивна: перед каждым вызовом send_message, send_file и установкой реакции текст проходит проверку на категории, которым в публичном чате не место. Примитивность — осознанная. Вот работающая иллюстрация (Python 3.10+, только стандартная библиотека):

import re

RULES = [
    ("service-marker", re.compile(r"[(?:[A-Z0-9]+_)*(?:SENT|FAIL)]")),
    ("traceback",      re.compile(r"Traceback (most recent call last)")),
    ("tmp-path",       re.compile(r"/tmp/[w./-]+")),
    ("runtime-meta",   re.compile(r"b(?:model|bytes|source_id)=S+")),
    ("dry-run-json",   re.compile(r'{[^{}]*"dry_run"s*:')),
    ("secret-like",    re.compile(r"b(?=[w-]*d)(?=[w-]*[A-Za-z])[w-]{32,}b")),
]

def check_outgoing(text: str):
    hits = [name for name, rx in RULES if rx.search(text)]
    return ("BLOCK", hits) if hits else ("SEND", [])

corpus = [
    "Кэш придётся греть заново, тут без вариантов.",
    "[IMAGE_SENT] model=flux-2 bytes=381224",
    "Traceback (most recent call last):n  File "bot.py", line 812",
    "Картинка готова: /tmp/johnny_img_8812.png",
    '{"dry_run": true, "action": "send_reply", "chat": -100500}',
    "Токен смени: sk-live-a81f0c22d94b47e3b6f2c1d0e9a8b7c6",
    "WAL включается одной прагмой, busy_timeout — второй.",
    "Сравни докер-слои: у тебя половина образа — кэш apt.",
]

for text in corpus:
    verdict, hits = check_outgoing(text)
    print(f"{verdict:5}  {','.join(hits) or '-':22}  " + text.replace("n", " ")[:52])

Прогон на маленьком корпусе исходящих строк:

SEND   -                       Кэш придётся греть заново, тут без вариантов.
BLOCK  service-marker,runtime-meta  [IMAGE_SENT] model=flux-2 bytes=381224
BLOCK  traceback               Traceback (most recent call last):   File "bot.py",
BLOCK  tmp-path                Картинка готова: /tmp/johnny_img_8812.png
BLOCK  dry-run-json            {"dry_run": true, "action": "send_reply", "chat": -1
BLOCK  secret-like             Токен смени: sk-live-a81f0c22d94b47e3b6f2c1d0e9a8b7c
SEND   -                       WAL включается одной прагмой, busy_timeout — второй.
SEND   -                       Сравни докер-слои: у тебя половина образа — кэш apt.

Public-send guard: последний фильтр перед отправкой в чат

Внимательный читатель четвёртой части поймает меня за руку: целую статью я объяснял, почему словарные фильтры — плохой инструмент, а тут предлагаю набор регулярок. Противоречия нет, и это важный момент. В четвёртой части словарь проигрывал на задаче классификации смысла — открытом классе, где список никогда не полон. Здесь задача другая: не дать известным категориям служебного текста уйти наружу. Класс закрытый, перечислимый и растёт только вместе с нашим собственным кодом. В правилах проекта это исключение прописано явно: словари допустимы для safety hard guard. Причём именно словарь здесь предпочтительнее модели: guard обязан быть детерминированным, дешёвым и работать даже тогда, когда LLM-слой лежит.

Асимметрия цены ошибок тоже перевёрнута относительно четвёртой части. Ложное срабатывание guard стоит одну недоставленную реплику — неприятно, видно в логе, чинится за минуту. Пропуск стоит traceback или служебный JSON на глазах у всего чата. Поэтому правила пишутся с запасом в сторону блокировки — например, реплика с легальным упоминанием пути /tmp в техническом споре тоже будет зарезана, и это принятый компромисс. По журналу, guard позже расширялся: в v0305 под фильтр попали внутренние блоки reference-контекста и dry-run-нагрузка автономных этапов, а аудит v0311 отдельно проверил остальные служебные блоки промпта и решил, что пользовательских данных они не несут — тот случай, когда результат аудита «патч не нужен» тоже фиксируется письменно.

Один процесс — свойство, а не надежда

Возвращаемся к инциденту с двумя процессами. Лечение состояло из двух половин, и обе обязательны.

Организационная: единственный способ запуска — systemd. Ручной старт в screen при живом сервисе запрещён правилами проекта. Контролируемые тесты под реальным аккаунтом выполняются только по схеме: systemctl stop → убедиться, что процессов нет → тест → systemctl start → убедиться, что процесс один. Скучно, зато исключает состояние «кто-то забыл выйти из screen».

Техническая: файловый lock, который процесс захватывает до открытия сессии Telethon. Порядок принципиален. Если сначала открыть сессию, а потом проверять lock, второй экземпляр успеет испортить сессионную базу до того, как поймёт, что он лишний. С lock до сессии второй запуск завершается чисто, не дотронувшись до неё. Две детали из журнала, которые легко упустить: CLI-режим (импорт модуля бота для офлайн-прогонов) lock не берёт — иначе тесты нельзя гонять при работающем сервисе; а завершение второго экземпляра сделано аккуратным SystemExit без traceback — потому что traceback в логах при каждом штатном срабатывании защиты приучает не читать логи.

Дубль входящего — не экзотика

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

Наивная защита пишется сама собой: проверить в базе, обрабатывали ли уже это msg_id, и если нет — обработать и записать. Между «проверить» и «записать» есть окно, и в него отлично проваливаются обе копии. Демо (окно гонки расширено паузой в 5 мс намеренно — иначе пример срабатывал бы нестабильно; в реальности окно короче, но оно есть):

import sqlite3, threading, os, time

DB = "/tmp/idem_demo.sqlite3"
sent, lock = [], threading.Lock()

def fresh_db():
    if os.path.exists(DB):
        os.remove(DB)
    con = sqlite3.connect(DB)
    con.execute("PRAGMA journal_mode=WAL")
    con.execute("CREATE TABLE processed(chat_id INT, msg_id INT, "
                "PRIMARY KEY(chat_id, msg_id))")
    con.commit(); con.close()

def handle_naive(chat_id, msg_id):
    con = sqlite3.connect(DB, timeout=30)
    seen = con.execute("SELECT 1 FROM processed WHERE chat_id=? AND msg_id=?",
                       (chat_id, msg_id)).fetchone()
    if seen is None:                       # проверили...
        time.sleep(0.005)                  # ...и уступили планировщику
        con.execute("INSERT OR IGNORE INTO processed VALUES(?,?)",
                    (chat_id, msg_id))
        con.commit()
        with lock:
            sent.append((chat_id, msg_id)) # ...а потом действуем
    con.close()

def handle_atomic(chat_id, msg_id):
    con = sqlite3.connect(DB, timeout=30)
    cur = con.execute("INSERT OR IGNORE INTO processed VALUES(?,?)",
                      (chat_id, msg_id))
    con.commit()
    claimed = cur.rowcount == 1            # захват и проверка — одно действие
    con.close()
    if claimed:
        with lock:
            sent.append((chat_id, msg_id))

def deliver_twice(handler):
    """Каждое сообщение доставляется двумя копиями, почти одновременно."""
    fresh_db(); sent.clear()
    for msg_id in range(1, 51):
        t1 = threading.Thread(target=handler, args=(-100500, msg_id))
        t2 = threading.Thread(target=handler, args=(-100500, msg_id))
        t1.start(); t2.start(); t1.join(); t2.join()
    dup = len(sent) - len(set(sent))
    print(f"{handler.__name__}: ответов отправлено {len(sent)}, "
          f"из них дублей {dup}")

deliver_twice(handle_naive)
deliver_twice(handle_atomic)

Каждое из 50 сообщений доставляется двумя копиями, копии обрабатываются параллельными потоками. Вывод:

handle_naive: ответов отправлено 100, из них дублей 50
handle_atomic: ответов отправлено 50, из них дублей 0

Дубль сообщения: check-then-act против атомарного захвата

Разница не в аккуратности, а в структуре: у атомарного варианта захват и проверка — одно действие базы данных. INSERT OR IGNORE в таблицу с первичным ключом (chat_id, msg_id) выполнится успешно ровно у одной копии, и только она увидит rowcount == 1. Окна между «посмотрел» и «записал» больше не существует — исчезает не конкретная гонка, а весь её класс. В боевом коде, по журналу, захват стоит в самом начале конвейера — до архивирования, до vision-анализа, до постановки в очередь ответа: смысл идемпотентности теряется, если до точки захвата дубль успевает произвести побочные эффекты.

Три коротких патча

Три находки закрылись без драмы, но в чек-листе они наравне с остальными.

Блокирующий HTTP в async-хендлере. Генерация изображения — это десятки секунд ожидания внешнего API. Выполненная синхронно внутри обработчика событий, она останавливает весь event loop: бот не читает чат, не отвечает, копит очередь. Лечение стандартное — вынос блокирующего вызова в поток через asyncio. to_thread. Поучительна здесь не техника, а то, как дефект прятался: в CLI-прогоне и в тихом тестовом чате однопоточность незаметна, потому что событий мало. Ломается это только под живой нагрузкой.

SQLite под конкурентной записью. База в режиме журнала по умолчанию плохо переносит несколько пишущих задач: вторая упирается в блокировку и падает с database is locked. Патч — три прагмы при каждом подключении:

PRAGMA journal_mode=WAL;      -- писатель не блокирует читателей
PRAGMA busy_timeout=30000;    -- ждать блокировку, а не падать сразу
PRAGMA synchronous=NORMAL;    -- разумный компромисс для WAL

Тонкость из журнала: прагмы применили не только в основном процессе, но и в модуле vision — конкуренция возникает именно между модулями, и половинчатое включение WAL не даёт почти ничего.

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

Ошибка, которой не было в логах

Седьмая находка — моя любимая, потому что она встроена в сам asyncio. Фоновая задача, запущенная через create_task, падает — и исключение не появляется нигде. Проверим:

import asyncio

async def flaky_background_job(payload: str):
    raise RuntimeError(f"vision API вернул пустой ответ для {payload}")

background = set()

async def service_naive():
    t = asyncio.create_task(flaky_background_job("msg_1017"))
    background.add(t)      # держим ссылку, как советует документация
    for sec in range(3):
        await asyncio.sleep(1)
        print(f"naive: сервис живёт {sec + 1} c, в логе пусто")

asyncio.run(service_naive())

Вывод — три секунды образцовой тишины:

naive: сервис живёт 1 c, в логе пусто
naive: сервис живёт 2 c, в логе пусто
naive: сервис живёт 3 c, в логе пусто

Знаменитое сообщение Task exception was never retrieved в этом сценарии появляется в stderr только тогда, когда объект задачи собирает сборщик мусора — в демо это происходит при завершении процесса. И здесь спрятана ловушка: документация asyncio справедливо советует держать сильную ссылку на задачу, чтобы её не убил сборщик мусора на середине. Но пока ссылка жива, не сработает и аварийный репортёр. Сервис под systemd работает неделями — значит, «при завершении процесса» на практике означает «никогда». Ошибка есть, следа нет.

Патч P0H заменяет прямые create_task на обёртку, которая ловит исключение в момент падения и пишет его в лог с именем задачи:

import asyncio, sys, traceback

async def flaky_background_job(payload: str):
    raise RuntimeError(f"vision API вернул пустой ответ для {payload}")

background = set()

def create_logged_task(coro, *, name: str):
    async def runner():
        try:
            await coro
        except Exception:
            print(f"[task-error] {name}:", file=sys.stderr)
            traceback.print_exc()
    t = asyncio.create_task(runner(), name=name)
    background.add(t)
    t.add_done_callback(background.discard)
    return t

async def service_wrapped():
    create_logged_task(flaky_background_job("msg_1017"), name="vision:msg_1017")
    await asyncio.sleep(1)

asyncio.run(service_wrapped())

Тот же сценарий с обёрткой даёт запись сразу, с контекстом:

[task-error] vision:msg_1017:
Traceback (most recent call last):
  ...
RuntimeError: vision API вернул пустой ответ для msg_1017

Имя задачи в логе — не косметика. «Где-то упал vision» и «упал vision для msg_1017» — это разное время диагностики, особенно когда фоновых задач десятки.

Рубильник и цена усиления

Два элемента границы не привязаны к конкретной находке, но без них картина неполная.

Первый — общий dry-run-рубильник P0I: переменная окружения, при которой блокируются все реальные побочные эффекты — отправка сообщений, файлов, реакций, вызовы image API. Важная деталь: в dry-run не создаются и фиктивные записи о «как бы отправленных» картинках, иначе тестовый прогон засоряет учёт, на который смотрит боевой режим. Такой рубильник превращает опасный класс проверок «а что сделает конвейер целиком» в безопасный: конвейер проходит весь путь до последнего шага и останавливается перед дверью.

Второй — обратная сторона всей этой брони. Усиление границы дважды задевало живые функции: после очередной волны guard-патчей у бота отвалились мелочи, за которые его, собственно, и держат в чате, — альтернативное обращение «Жони», морфология подробных ответов, часть реакционного контракта, телеметрия задержек. Отдельным патчем P0J всё это восстанавливали и закрывали регрессионными проверками. Вывод из этого эпизода вошёл в правила проекта: патч границы — такой же патч, как любой другой, и точно так же способен сломать поведение. Бронежилет, который мешает дышать, снимут.

Чек-лист границы

Итог серии P0 в проекте оформлен как правило: новая подсистема с побочным эффектом в Telegram или внешнем API не допускается до первого live-теста, пока не закрыт список из десяти пунктов.

Чек-лист границы production перед первым live-тестом

Текстом, чтобы можно было унести с собой:

# Пункт Что проверяет
1 Граница истории служебные данные не живут в диалоге
2 Public-send guard известный служебный текст не уходит в чат
3 Single-instance lock второй экземпляр умирает до сессии
4 Идемпотентность дубль входящего не даёт второй ответ
5 Enabled-gate выключенная фича не тратит вызовы API
6 Политика отказа понятно, что система делает при ошибке
7 Rate/cooldown у побочного эффекта есть потолок частоты
8 Async-safe I/O хендлер не блокируется на внешних вызовах
9 SQLite: WAL + busy_timeout конкурентная запись не роняет задачи
10 Отдельная телеметрия учёт и аудит — в своих таблицах и логах

Пункты 5-7 в этой статье подробно не разбирались: политика частоты и кулдаунов — это вторая часть серии, а enabled-gate и политика отказа заслуживают разговора в контексте fail-soft работы с LLM API. Существенно другое: список применяется целиком. Половина брони — это дыра в броне, просто задокументированная.

Ограничения

Честные оговорки, без которых картина получилась бы глаже, чем есть.

Guard — это перечислимый список known-bad-категорий. Он не защищает от утечки того, о чём мы не подумали: новая подсистема с новым форматом служебных строк требует нового правила, и до этого правила формат наружу проходит. Дисциплина «служебное — только в служебные структуры» снижает вероятность, но не обнуляет её.

Идемпотентность в примере опирается на первичный ключ одной SQLite-базы. При нескольких экземплярах бота с раздельными базами схема не работает — но и весь дизайн проекта построен на строго одном экземпляре, что честно ограничивает масштабирование вертикалью.

Dry-run проверяет конвейер, но не среду: права файловой системы, поведение systemd, состояние сессии Telethon он не трогает. Поэтому финальная ступень в проекте — всё равно controlled live-тест по регламенту, а не только сухой прогон.

Наконец, числа в демо — синтетика. Пауза в 5 мс делает окно гонки воспроизводимым в статье; в реальном коде окно уже, и наивный вариант может месяцами не стрелять — что хуже честного немедленного отказа, потому что расслабляет.

Что вынести

Граница production — отдельный слой со своим предметом. Он не улучшает ответы; он гарантирует свойства системы: один процесс, один ответ на сообщение, ничего служебного наружу, ни одной молчаливой ошибки. Тесты функций эти свойства не проверяют — их приходится проверять аудитом среды и закрывать чек-листом.

Ошибки границы объединяет механика: окно между проверкой и действием. Дубль сообщений — окно между SELECT и INSERT. Два процесса — окно между запуском и захватом ресурса. Устаревший план — окно между решением и отправкой. Лечение везде одно: сделать захват атомарным или перепроверить мир после паузы.

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

Все демо запускаются на Python 3.10+ без зависимостей: каждый python-блок самодостаточен — скопируйте его в файл и запустите. Публичные обезличенные материалы проекта — высокоуровневая архитектура, слоевая модель runtime и открытый чек-лист регрессий — собраны в репозитории: https://github.com/UtochkaI/johnny-telegram-bot-habr. Боевого кода, промптов и конфигурации там нет, только безопасные заметки к статьям. Код в тексте написан с нуля для иллюстрации.

Автор не входит в состав редакции iXBT.com (подробнее »)
Об авторе
После 15 лет разработки учусь вайбкодить. Пытаюсь забыть Python, чтоб освободить память для промптов.

0 комментариев

Добавить комментарий

Сейчас на главной

Новости

Публикации

На острове Медвежий описали крупнейшую амфибию Триасового периода: 3-метровый хищник с 70-сантиметровой пастью доказал морское происхождение группы

Летом 1948 года участники британской биологической экспедиции проводили исследования на острове Медвежий — изолированном участке суши в Баренцевом море, расположенном между северным...

Почему культовый Marantz PMD-430 портил звук из-за одной детальки за 100 рублей

В 1985 году профессиональный репортёрский магнитофон Marantz PMD-430 вынимал из бюджета редакции сумму, на которую можно было обставить целую радиостудию. Журналисты буквально выбивали его у...

1 из 7 000 человек обладает встроенной защитой от ожирения: всё дело в редком варианте гена FNIP1

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

Выбираем смартфон Samsung в 2026 году: подробный разбор актуальной линейки компании

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

Почему у перцев много цветков, но почти нет плодов: растение цветёт и будто передумывает

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

Точный прогноз погоды на 71 день станет реальностью: ученые рассчитали абсолютный предел предсказуемости

Современные метеорологические службы способны составлять точные прогнозы погоды на срок до двух недель. Начиная с пятнадцатого дня, качество расчетов существенно снижается, а результаты становятся...