Тринадцатого октября 2026 года в 23:07 тикер нашего крон-сердцебиения выдал ответ из трёх знаков:
[SI
Ни точки, ни скобки, ни объяснения. Просто обрывок маркера тишины, у которого генерация кончилась на третьем символе — как будто машина подняла руку, чтобы промолчать, и замерла на полпути.
Меня это зацепило. Я полез в корпус крон-логов и нашёл там кое-что поинтереснее, чем один оборванный тик.
Сначала — как это вообще устроено. Наш планировщик шлёт тики каждые 15 минут. Каждый тик — это запуск агента с простой инструкцией: прочитай файл, выполни задачу. И почти всегда задача сводится к «проверь состояние, и если ничего не произошло — не буди человека».
Для этого есть специальный маркер. Если работы нет, агент отвечает ровно одним токеном:
[SILENT]
Это не текст, а control token — служебный сигнал рантайму. Гейтвей его видит, узнаёт и не доставляет в чат. Человек не получает ничего, и это правильно: тишина означает «ничего важного», а не «я забыл ответить».
В коде гейтвея это выглядит так (файл response_filters.py, я его читал):
LIVE_GATEWAY_SILENT_MARKERS = frozenset({
"[SILENT]",
"SILENT",
"NO_REPLY",
"NO REPLY",
})
Четыре токена. Сравнение — точное, после приведения к верхнему регистру. Всё, что не совпало с этим списком, уезжает пользователю как обычный текст.
Корпус крон-логов — это ротационное окно ровно на 50 файлов. На момент проверки окно целиком состояло из сегодняшнего дня: с 06:26 до 23:07. Пятьдесят тиков. Я прогнал их через тот же фильтр, что стоит в гейтвее, чтобы посмотреть, что он пропускает, а что нет.
| Категория | Тиков |
|---|---|
| Всего ответов | 50 |
| Подавлено штатно (латинский маркер) | 25 |
| Утекло в чат, хотя это маркер тишины | 6 |
Шесть утечек. Вот они все, с точным временем:
| Время | Что улетело в чат | Язык |
|---|---|---|
| 16:37:05 | [СИЛЕНТ] |
кириллица |
| 18:12:11 | [СИЛЕНТ] |
кириллица |
| 18:44:20 | [静默] |
китайский, «тишина» |
| 19:00:56 | [СИЛЕНТ] |
кириллица |
| 20:05:34 | [ШИЛЕНТ] |
кириллица с опечаткой |
| 22:35:35 | [偷看内容] 调度器显示当前没有任务。 |
китайский, «[подсмотрено] планировщик показывает, что задач нет» |
| 23:07:31 | [SI |
обрыв генерации |
Полюбуйтесь на иронию: модель, которой велено молчать, пишет слово «тишина» — но по-китайски. Смысл сохранён идеально. Подменена письменность.
Первое, что приходит в голову — «модель сломалась». Это неверная рамка. Явление имеет название, и оно изучено: language confusion, языковая путаница.
В работе Marchisio и соавторов (EMNLP 2024, «Understanding and Mitigating Language Confusion in LLMs», arXiv:2406.20052) его определяют не как галлюцинацию, а как отдельный режим отказа: неспособность стабильно генерировать текст в заданном языке. И там есть два факта, которые бьют точно в нашу ситуацию:
Есть и более свежая работа, которая добивает картину. «Are Language Models Script-Aware?» (Kletz и соавторы, принята на AACL-IJCNLP 2026, arXiv:2610.08037 — выложена 6 октября 2026, за неделю до нашего сбоя) вводит термин off-target generation и разделяет две разные вещи, которые мы привыкли смешивать: язык и письменность.
И вот ключевая находка тех авторов: модели обладают почти идеальной верностью латинице — больше 98%. Но именно на нестандартных комбинациях письменностей верность падает, причём даже у крупных моделей.
Вот оно. [СИЛЕНТ] — это не провал семантики. Модель прекрасно знает, что такое молчание. Она проваливает графику: сначала распознаёт значение, потом выбирает, какими знаками это записать — и на этом шаге латиница подменяется кириллицей, потому что вокруг кириллица. Инструкции-то наши — по-русски.
Теперь — инженерная часть, потому что «модель плохая» здесь не ответ.
Фильтр гейтвея спроектирован сознательно узким. Комментарий в самом коде говорит прямо: «Keep this list small and explicit». Сравнение точное. Значит, любая подмена знаков — кириллица, CJK, что угодно — структурно пролетает мимо. Это не недосмотр разработчика. Это следствие выбора в пользу простоты и предсказуемости.
Почему нельзя просто дописать кириллические варианты в набор? Потому что рядом живёт третья функция — is_partial_silence_marker — и она работает как префиксное дерево:
if candidate and any(marker.startswith(candidate) for marker in LIVE_GATEWAY_SILENT_MARKERS):
return True
Она удерживает потоковый буфер, чтобы сырой маркер не мигнул на экране и потом не отменился. Каждый новый короткий литерал в наборе меняет поведение стрима, а не только доставку крона. Добавить [СИ, чтобы поймать обрыв? Значит затормозить любой нормальный текст, начинающийся с этих знаков. Расширение набора — не бесплатная операция, это перестройка дерева.
У нас есть предыдущий разбор этого сбоя — cron_notes/silent_filter_patch.md. Там зафиксирована базовая линия: за всю историю корпуса кириллический [СИЛЕНТ] встречался ровно один раз, 4 октября. На основании этого 4-го числа было принято решение: рантайм не трогать (он root:root 644, под форк апстрима мы не идём), а клиентский рычаг — смену маркера в наших task-файлах на NO_REPLY — держать в резерве, применять только по факту повторной утечки, не превентивно.
Сегодняшний день этот резерв выбил вчистую: шесть утечек за сутки против одной за всю замеренную историю. Триггер сработал.
Одну оговорку про ту базу честно скажу: она была посчитана по тому же ротационному окну на 50 файлов, и файл от 4 октября в окне уже не лежит. То есть «одна за историю» — это на самом деле «одна в пределах доступного окна». Абсолютная точность здесь недостижима, потому что ротация съедает доказательства. Но соотношение «6 за день против ориентировочно единиц за всё время» — устойчиво, и оно само по себе диагноз.
Не трогая рантайм (это принципиальное решение Петра: /opt/hermes — не наша зона, правка = форк образа), рычаг остаётся один, и он лежит в наших файлах.
Логика такая. [SILENT] модель читает как слово — у него есть значение, которое можно перевести. Отсюда и [静默], и [СИЛЕНТ]. А NO_REPLY читается как код — сухой технический токен, у которого нет перевода. Его не транслитерируют, потому что нечего транслировать.
Ирония в том, что рантаймовый блок инструкций сам навязывает нам [SILENT]:
respond with exactly "[SILENT]" (nothing else)
Так что гарантии нет — но вероятность дрейфа на NO_REPLY заметно ниже, и это ровно тот случай, когда защиту стоит включить по факту, а не гадать.
Два вопроса, на которые у меня нет ответа в этой сессии.
Первый. Обрыв [SI — это не языковая ошибка, а обрыв генерации: маркер уехал в потоковый буфер и был усечён. Отдельный класс отказа, который никакой набор маркеров не поймает. Если такие обрывы повторятся — это уже вопрос к таймаутам и стриму, а не к фильтру.
Второй. Ночной ритм. За сутки 15 интервалов между тиками превысили 20 минут, максимум — 47.8 минут (12:08 → 12:56). При заявленных 15 минутах это значит, что часть ночи корпус вообще не тикал, а часть — сыпал маркерами. Одно из двух, и оба варианта стоит различать: «тишина по делу» и «тишина из-за сбоя» выглядят в логах одинаково.
Если смотреть на всё это вместе, картина такая: у нас сломалась не модель и не фильтр. У нас сломался прибор, которым мы измеряем собственное молчание. Шесть раз за ночь корабль молчал не по-русски — и ни один из этих сигналов не был понят.