Граница production: что обязано быть у LLM-бота до первого сообщения в живой чат. Часть 5
Пятая часть серии про инженерию Telegram-бота с характером. В прошлых частях бот учился молчать, находить адресата и классифицировать реплики без словарей. Все эти подсистемы объединяет одно: они решают, что и кому сказать. Эта часть — про другой слой, который решает, имеет ли система вообще право открывать рот. Аудит перед первыми автономными репликами нашёл в работающем боте семь проблем — и ни одна из них не была видна ни в одном тесте.
Речь о «Джонни» — язвительном инженерном персонаже в Telegram, который начинался как развлечение на вечер и дорос до системы с маршрутизацией, границами контекста и регрессионными проверками. Дальше — авторский разбор одного слоя моего бота. Код-примеры написаны с нуля для иллюстрации и кодом бота не являются; версии патчей и параметры приводятся по журналу проекта и независимо не проверялись — отношусь к ним как к авторским данным. Всё, что помечено как вывод демо, вычисляется кодом из статьи и воспроизводится запуском.
Инцидент, с которого всё началось
Однажды бот перестал нормально отвечать. Сервис числился запущенным, логи писались, ошибок в них не было — а в чате тишина вперемешку с невнятным поведением. Диагноз оказался унизительно простым: процессов бота было два. Один запустил systemd, второй остался жить в screen после ручной отладки. Оба держали одну и ту же сессию Telethon — а это SQLite-файл, — и база сессии ушла в состояние locked. Каждый процесс по отдельности был здоров. Больна была конфигурация, в которой их двое.
Показательно не то, что это случилось, а то, что ни один тест этого не ловил и не мог поймать. Юнит-тесты проверяют функции. Регрессионный набор проверяет маршрутизацию. CLI-прогон проверяет ответы. Никто не проверяет утверждение «в системе ровно один экземпляр процесса» — оно лежит вне кода, в среде исполнения.
Этот инцидент вместе с подготовкой к автономным репликам (бот получал право писать в чат сам, без входящего сообщения — тема отдельного разбора) привёл к аудиту всего, что касается побочных эффектов. Дальше — что аудит нашёл и как это закрывалось. В журнале проекта эта серия патчей называется P0, production boundary.
Семь находок
Аудит шёл не по коду, а по вопросу: «что произойдёт в живом чате, если это сломается?» Список получился таким:
- Служебные маркеры хранились внутри истории диалога. Строки вида [IMAGE_SENT], пути к временным файлам, метаданные model= лежали в той же структуре, что и реплики людей.
- Отправка в чат шла без последнего фильтра. Между «текст готов» и send_message не стояло ничего.
- Блокирующий HTTP жил внутри async-хендлера. Генерация изображения на десятки секунд останавливала обработку всего остального.
- Ничто не мешало запустить два экземпляра бота. См. инцидент выше.
- Не было идемпотентности входящих. Повторная доставка одного сообщения означала повторную обработку и второй ответ.
- SQLite работала без WAL. Конкурентная запись из нескольких задач упиралась в блокировки.
- Ошибки фоновых задач исчезали без следа. Задача падала — и никто об этом не узнавал.
Семь находок одного аудита и патчи, которыми они закрыты
Общая черта всех семи пунктов: это не баги функций. Это отсутствующие свойства системы. Функция «сгенерировать ответ» может быть идеальной — и всё равно отправить в публичный чат 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. Боевого кода, промптов и конфигурации там нет, только безопасные заметки к статьям. Код в тексте написан с нуля для иллюстрации.





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